From caaf741691c9d44a9a186d6ce924fecb04f4f347 Mon Sep 17 00:00:00 2001 From: czaks Date: Fri, 22 Apr 2016 05:35:43 +0200 Subject: [SECURITY] keep up with modern password hashing standards --- install.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'install.php') diff --git a/install.php b/install.php index 95caf8ec..5a2d724a 100644 --- a/install.php +++ b/install.php @@ -1,10 +1,12 @@ $config, @@ -551,6 +553,9 @@ if (file_exists($config['has_installed'])) { foreach ($boards as &$board) { query(sprintf('ALTER TABLE ``posts_%s`` ADD `slug` VARCHAR(255) DEFAULT NULL AFTER `embed`;', $board['uri'])) or error(db_error()); } + case '4.9.93': + query('ALTER TABLE ``mods`` CHANGE `password` `password` VARCHAR(255) NOT NULL;') or error(db_error()); + query('ALTER TABLE ``mods`` CHANGE `salt` `salt` VARCHAR(64) NOT NULL;') or error(db_error()); case false: // TODO: enhance Tinyboard -> vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); -- cgit v1.3 From 7c3126866cd291d515ab5cc83d4635c3c95c6d85 Mon Sep 17 00:00:00 2001 From: czaks Date: Thu, 5 May 2016 06:43:22 +0200 Subject: ease the migration process for the previous security patch (by introducing another migration); restore php 5.4 compatibility (introducing a polyfill system) --- inc/functions.php | 2 ++ inc/mod/auth.php | 10 +++++----- inc/mod/pages.php | 18 +++++++++--------- inc/polyfill.php | 28 ++++++++++++++++++++++++++++ install.php | 4 +++- install.sql | 2 +- 6 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 inc/polyfill.php (limited to 'install.php') diff --git a/inc/functions.php b/inc/functions.php index 515e3e55..3f5ed3b0 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -18,6 +18,8 @@ require_once 'inc/template.php'; require_once 'inc/database.php'; require_once 'inc/events.php'; require_once 'inc/api.php'; +require_once 'inc/polyfill.php'; + if (!extension_loaded('gettext')) { require_once 'inc/lib/gettext/gettext.inc'; } diff --git a/inc/mod/auth.php b/inc/mod/auth.php index fa1a0f4f..d877b89f 100644 --- a/inc/mod/auth.php +++ b/inc/mod/auth.php @@ -76,20 +76,20 @@ function generate_salt() { function login($username, $password) { global $mod, $config; - $query = prepare("SELECT `id`, `type`, `boards`, `password`, `salt` FROM ``mods`` WHERE `username` = :username"); + $query = prepare("SELECT `id`, `type`, `boards`, `password`, `version` FROM ``mods`` WHERE `username` = :username"); $query->bindValue(':username', $username); $query->execute() or error(db_error($query)); if ($user = $query->fetch(PDO::FETCH_ASSOC)) { - list($version, $ok) = test_password($user['password'], $user['salt'], $password); + list($version, $ok) = test_password($user['password'], $user['version'], $password); if ($ok) { if ($config['password_crypt_version'] > $version) { // It's time to upgrade the password hashing method! - list ($user['salt'], $user['password']) = crypt_password($password); - $query = prepare("UPDATE ``mods`` SET `password` = :password, `salt` = :salt WHERE `id` = :id"); + list ($user['version'], $user['password']) = crypt_password($password); + $query = prepare("UPDATE ``mods`` SET `password` = :password, `version` = :version WHERE `id` = :id"); $query->bindValue(':password', $user['password']); - $query->bindValue(':salt', $user['salt']); + $query->bindValue(':version', $user['version']); $query->bindValue(':id', $user['id']); $query->execute() or error(db_error($query)); } diff --git a/inc/mod/pages.php b/inc/mod/pages.php index a07de4c7..8b6f73c4 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -1734,12 +1734,12 @@ function mod_user($uid) { } if ($_POST['password'] != '') { - list($salt, $password) = crypt_password($_POST['password']); + list($version, $password) = crypt_password($_POST['password']); - $query = prepare('UPDATE ``mods`` SET `password` = :password, `salt` = :salt WHERE `id` = :id'); + $query = prepare('UPDATE ``mods`` SET `password` = :password, `version` = :version WHERE `id` = :id'); $query->bindValue(':id', $uid); $query->bindValue(':password', $password); - $query->bindValue(':salt', $salt); + $query->bindValue(':version', $version); $query->execute() or error(db_error($query)); modLog('Changed password for ' . utf8tohtml($_POST['username']) . ' (#' . $user['id'] . ')'); @@ -1760,12 +1760,12 @@ function mod_user($uid) { if (hasPermission($config['mod']['change_password']) && $uid == $mod['id'] && isset($_POST['password'])) { if ($_POST['password'] != '') { - list($salt, $password) = crypt_password($_POST['password']); + list($version, $password) = crypt_password($_POST['password']); - $query = prepare('UPDATE ``mods`` SET `password` = :password, `salt` = :salt WHERE `id` = :id'); + $query = prepare('UPDATE ``mods`` SET `password` = :password, `version` = :version WHERE `id` = :id'); $query->bindValue(':id', $uid); $query->bindValue(':password', $password); - $query->bindValue(':salt', $salt); + $query->bindValue(':version', $version); $query->execute() or error(db_error($query)); modLog('Changed own password'); @@ -1832,12 +1832,12 @@ function mod_user_new() { if (!isset($config['mod']['groups'][$type]) || $type == DISABLED) error(sprintf($config['error']['invalidfield'], 'type')); - list($salt, $password) = crypt_password($_POST['password']); + list($version, $password) = crypt_password($_POST['password']); - $query = prepare('INSERT INTO ``mods`` VALUES (NULL, :username, :password, :salt, :type, :boards)'); + $query = prepare('INSERT INTO ``mods`` VALUES (NULL, :username, :password, :version, :type, :boards)'); $query->bindValue(':username', $_POST['username']); $query->bindValue(':password', $password); - $query->bindValue(':salt', $salt); + $query->bindValue(':version', $version); $query->bindValue(':type', $type); $query->bindValue(':boards', implode(',', $boards)); $query->execute() or error(db_error($query)); diff --git a/inc/polyfill.php b/inc/polyfill.php new file mode 100644 index 00000000..ac40a00a --- /dev/null +++ b/inc/polyfill.php @@ -0,0 +1,28 @@ + $i ? $i : 0]) ^ ord($theirs[$i]); + } + + return $answer === 0 && $olen === $tlen; + } +} diff --git a/install.php b/install.php index 5a2d724a..968c41de 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); diff --git a/install.sql b/install.sql index fbf220c1..7e661450 100644 --- a/install.sql +++ b/install.sql @@ -132,7 +132,7 @@ CREATE TABLE IF NOT EXISTS `mods` ( `id` smallint(6) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(30) NOT NULL, `password` varchar(256) CHARACTER SET ascii NOT NULL COMMENT 'SHA256', - `salt` varchar(64) CHARACTER SET ascii NOT NULL, + `version` varchar(64) CHARACTER SET ascii NOT NULL, `type` smallint(2) NOT NULL, `boards` text CHARACTER SET utf8 NOT NULL, PRIMARY KEY (`id`), -- cgit v1.3 From 8dac72e924d658e66c5efe3334aa8ab41d00daca Mon Sep 17 00:00:00 2001 From: czaks Date: Thu, 5 May 2016 09:16:09 +0200 Subject: update installer --- install.php | 103 +++++++++++++++++++++++++++------------- templates/installer/config.html | 3 ++ 2 files changed, 73 insertions(+), 33 deletions(-) (limited to 'install.php') diff --git a/install.php b/install.php index 968c41de..7a146c8e 100644 --- a/install.php +++ b/install.php @@ -581,6 +581,25 @@ if (file_exists($config['has_installed'])) { die(Element('page.html', $page)); } +function create_config_from_array(&$instance_config, &$array, $prefix = '') { + foreach ($array as $name => $value) { + if (is_array($value)) { + $instance_config .= "\n"; + create_config_from_array($instance_config, $value, $prefix . '[\'' . addslashes($name) . '\']'); + $instance_config .= "\n"; + } else { + $instance_config .= ' $config' . $prefix . '[\'' . addslashes($name) . '\'] = '; + + if (is_numeric($value)) + $instance_config .= $value; + else + $instance_config .= "'" . addslashes($value) . "'"; + + $instance_config .= ";\n"; + } + } +} + if ($step == 0) { // Agreeement $page['body'] = ' @@ -614,7 +633,7 @@ if ($step == 0) { 'installed' => extension_loaded('pdo'), 'required' => true ), - 'PDO' => array( + 'GD' => array( 'installed' => extension_loaded('gd'), 'required' => true ), @@ -627,17 +646,17 @@ if ($step == 0) { $tests = array( array( 'category' => 'PHP', - 'name' => 'PHP ≥ 5.3', - 'result' => PHP_VERSION_ID >= 50300, + 'name' => 'PHP ≥ 5.4', + 'result' => PHP_VERSION_ID >= 50400, 'required' => true, - 'message' => 'vichan requires PHP 5.3 or better.', + 'message' => 'vichan requires PHP 5.4 or better.', ), array( 'category' => 'PHP', - 'name' => 'PHP ≥ 5.4', - 'result' => PHP_VERSION_ID >= 50400, + 'name' => 'PHP ≥ 5.6', + 'result' => PHP_VERSION_ID >= 50600, 'required' => false, - 'message' => 'vichan works best on PHP 5.4 or better.', + 'message' => 'vichan works best on PHP 5.6 or better.', ), array( 'category' => 'PHP', @@ -694,6 +713,7 @@ if ($step == 0) { 'result' => $can_exec && shell_exec('which convert'), 'required' => false, 'message' => '(Optional) `convert` was not found or executable; command-line ImageMagick image processing cannot be enabled.', + 'effect' => function (&$config) { $config['thumb_method'] = 'convert'; }, ), array( 'category' => 'Image processing', @@ -708,6 +728,7 @@ if ($step == 0) { 'result' => $can_exec && shell_exec('which gm'), 'required' => false, 'message' => '(Optional) `gm` was not found or executable; command-line GraphicsMagick (faster than ImageMagick) cannot be enabled.', + 'effect' => function (&$config) { $config['thumb_method'] = 'gm'; }, ), array( 'category' => 'Image processing', @@ -715,13 +736,25 @@ if ($step == 0) { 'result' => $can_exec && shell_exec('which gifsicle'), 'required' => false, 'message' => '(Optional) `gifsicle` was not found or executable; you may not use `convert+gifsicle` for better animated GIF thumbnailing.', + 'effect' => function (&$config) { if ($config['thumb_method'] == 'gm') $config['thumb_method'] = 'gm+gifsicle'; + if ($config['thumb_method'] == 'convert') $config['thumb_method'] = 'convert+gifsicle'; }, ), array( 'category' => 'Image processing', - 'name' => '`md5sum` (quick file hashing)', + 'name' => '`md5sum` (quick file hashing on GNU/Linux)', + 'prereq' => '', 'result' => $can_exec && shell_exec('echo "vichan" | md5sum') == "141225c362da02b5c359c45b665168de -\n", 'required' => false, - 'message' => '(Optional) `md5sum` was not found or executable; file hashing for multiple images will be slower.', + 'message' => '(Optional) `md5sum` was not found or executable; file hashing for multiple images will be slower. Ignore if not using Linux.', + 'effect' => function (&$config) { $config['gnu_md5'] = true; }, + ), + array( + 'category' => 'Image processing', + 'name' => '`/sbin/md5` (quick file hashing on BSDs)', + 'result' => $can_exec && shell_exec('echo "vichan" | /sbin/md5 -r') == "141225c362da02b5c359c45b665168de\n", + 'required' => false, + 'message' => '(Optional) `/sbin/md5` was not found or executable; file hashing for multiple images will be slower. Ignore if not using BSD.', + 'effect' => function (&$config) { $config['bsd_md5'] = true; }, ), array( 'category' => 'File permissions', @@ -737,6 +770,13 @@ if ($step == 0) { 'required' => true, 'message' => 'You must give vichan permission to create (and write to) the templates/cache directory or performance will be drastically reduced.' ), + array( + 'category' => 'File permissions', + 'name' => getcwd() . '/tmp/cache', + 'result' => is_dir('tmp/cache') && is_writable('tmp/cache'), + 'required' => true, + 'message' => 'You must give vichan permission to write to the tmp/cache directory.' + ), array( 'category' => 'File permissions', 'name' => getcwd() . '/inc/instance-config.php', @@ -760,17 +800,27 @@ if ($step == 0) { 'message' => 'vichan is still beta software and it\'s not going to come out of beta any time soon. As there are often many months between releases yet changes and bug fixes are very frequent, it\'s recommended to use the git repository to maintain your vichan installation. Using git makes upgrading much easier.' ) ); - + $config['font_awesome'] = true; + $additional_config = array(); + foreach ($tests as $test) { + if ($test['result'] && $test['effect']) { + $test['effect']($additional_config); + } + } + $more = ''; + create_config_from_array($more, $additional_config); + $_SESSION['more'] = $more; + echo Element('page.html', array( 'body' => Element('installer/check-requirements.html', array( 'extensions' => $extensions, 'tests' => $tests, - 'config' => $config + 'config' => $config, )), 'title' => 'Checking environment', - 'config' => $config + 'config' => $config, )); } elseif ($step == 2) { // Basic config @@ -781,14 +831,18 @@ if ($step == 0) { echo Element('page.html', array( 'body' => Element('installer/config.html', array( - 'config' => $config + 'config' => $config, + 'more' => $more, )), 'title' => 'Configuration', 'config' => $config )); } elseif ($step == 3) { + $more = $_POST['more']; + unset($_POST['more']); + $instance_config = -' $value) { - if (is_array($value)) { - $instance_config .= "\n"; - create_config_from_array($instance_config, $value, $prefix . '[\'' . addslashes($name) . '\']'); - $instance_config .= "\n"; - } else { - $instance_config .= ' $config' . $prefix . '[\'' . addslashes($name) . '\'] = '; - - if (is_numeric($value)) - $instance_config .= $value; - else - $instance_config .= "'" . addslashes($value) . "'"; - - $instance_config .= ";\n"; - } - } - } - create_config_from_array($instance_config, $_POST); + $instance_config .= "\n"; + $instance_config .= $more; $instance_config .= "\n"; if (@file_put_contents('inc/instance-config.php', $instance_config)) { diff --git a/templates/installer/config.html b/templates/installer/config.html index 193bb98b..973328f5 100644 --- a/templates/installer/config.html +++ b/templates/installer/config.html @@ -87,6 +87,9 @@ Miscellaneous + + +

-- cgit v1.3 From 985c113190f077ffe60abbf89642543851076eb9 Mon Sep 17 00:00:00 2001 From: czaks Date: Thu, 5 May 2016 09:18:36 +0200 Subject: ... --- install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'install.php') diff --git a/install.php b/install.php index 7a146c8e..21c573d1 100644 --- a/install.php +++ b/install.php @@ -805,7 +805,7 @@ if ($step == 0) { $additional_config = array(); foreach ($tests as $test) { - if ($test['result'] && $test['effect']) { + if ($test['result'] && isset($test['effect'])) { $test['effect']($additional_config); } } -- cgit v1.3 From 89fe3db55608385cb4a7ba8d7ac693d6fe75ff0a Mon Sep 17 00:00:00 2001 From: czaks Date: Thu, 5 May 2016 09:25:47 +0200 Subject: ... --- install.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'install.php') diff --git a/install.php b/install.php index 21c573d1..3aa8f56f 100644 --- a/install.php +++ b/install.php @@ -600,6 +600,8 @@ function create_config_from_array(&$instance_config, &$array, $prefix = '') { } } +session_start(); + if ($step == 0) { // Agreeement $page['body'] = ' @@ -832,7 +834,7 @@ if ($step == 0) { echo Element('page.html', array( 'body' => Element('installer/config.html', array( 'config' => $config, - 'more' => $more, + 'more' => $_SESSION['more'], )), 'title' => 'Configuration', 'config' => $config -- cgit v1.3 From aa98ca337e327fb1d8e77e124bf49922190246c4 Mon Sep 17 00:00:00 2001 From: czaks Date: Fri, 6 May 2016 14:36:14 +0200 Subject: i think this lump of code deserves a version bump; v5.1.0 here --- install.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'install.php') diff --git a/install.php b/install.php index 3aa8f56f..c5bbdf36 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); -- cgit v1.3 From 91c02c3ec41e5c7fa28e70ec4c9b4194ec941620 Mon Sep 17 00:00:00 2001 From: czaks Date: Fri, 6 May 2016 16:21:30 +0200 Subject: board pages: add a migration --- install.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) (limited to 'install.php') diff --git a/install.php b/install.php index c5bbdf36..5a6512ba 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); -- cgit v1.3 From 505adffcdca1c1141b7b46f862b7939f2f4acf45 Mon Sep 17 00:00:00 2001 From: Fredrick Brennan Date: Fri, 3 Apr 2015 14:56:28 +0800 Subject: Cyclical threads ♺ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inc/api.php | 1 + inc/config.php | 5 +++++ inc/functions.php | 8 +++++++- inc/mod/pages.php | 22 ++++++++++++++++++++++ install.php | 7 ++++++- mod.php | 1 + post.php | 11 ++++++++++- templates/post/post_controls.html | 8 +++++++- templates/post_thread.html | 13 ++++++++++--- templates/posts.sql | 1 + 10 files changed, 70 insertions(+), 7 deletions(-) (limited to 'install.php') diff --git a/inc/api.php b/inc/api.php index b2d8adec..b280c25b 100644 --- a/inc/api.php +++ b/inc/api.php @@ -32,6 +32,7 @@ class Api { 'images' => 'images', 'sticky' => 'sticky', 'locked' => 'locked', + 'cycle' => 'cyclical', 'bump' => 'last_modified', 'embed' => 'embed', ); diff --git a/inc/config.php b/inc/config.php index 3fdb8d2f..82fdc882 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1245,6 +1245,8 @@ $config['mod']['link_bumpunlock'] = '[-Sage]'; $config['mod']['link_editpost'] = '[Edit]'; $config['mod']['link_move'] = '[Move]'; + $config['mod']['link_cycle'] = '[Cycle]'; + $config['mod']['link_uncycle'] = '[-Cycle]'; // Moderator capcodes. $config['capcode'] = ' ## %s'; @@ -1388,6 +1390,9 @@ $config['mod']['deletebyip_global'] = ADMIN; // Sticky a thread $config['mod']['sticky'] = MOD; + // Cycle a thread + $config['mod']['cycle'] = MOD; + $config['cycle_limit'] = &$config['reply_limit']; // Lock a thread $config['mod']['lock'] = MOD; // Post in a locked thread diff --git a/inc/functions.php b/inc/functions.php index e9692d56..1bd1f32f 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -1021,7 +1021,7 @@ function insertFloodPost(array $post) { function post(array $post) { global $pdo, $board; - $query = prepare(sprintf("INSERT INTO ``posts_%s`` VALUES ( NULL, :thread, :subject, :email, :name, :trip, :capcode, :body, :body_nomarkup, :time, :time, :files, :num_files, :filehash, :password, :ip, :sticky, :locked, 0, :embed, :slug)", $board['uri'])); + $query = prepare(sprintf("INSERT INTO ``posts_%s`` VALUES ( NULL, :thread, :subject, :email, :name, :trip, :capcode, :body, :body_nomarkup, :time, :time, :files, :num_files, :filehash, :password, :ip, :sticky, :locked, :cycle, 0, :embed, :slug)", $board['uri'])); // Basic stuff if (!empty($post['subject'])) { @@ -1061,6 +1061,12 @@ function post(array $post) { $query->bindValue(':locked', false, PDO::PARAM_INT); } + if ($post['op'] && $post['mod'] && isset($post['cycle']) && $post['cycle']) { + $query->bindValue(':cycle', true, PDO::PARAM_INT); + } else { + $query->bindValue(':cycle', false, PDO::PARAM_INT); + } + if ($post['mod'] && isset($post['capcode']) && $post['capcode']) { $query->bindValue(':capcode', $post['capcode'], PDO::PARAM_INT); } else { diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 6d1e20b4..303fa3dd 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -1089,6 +1089,28 @@ 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) { + global $config; + + if (!openBoard($board)) + error($config['error']['noboard']); + + if (!hasPermission($config['mod']['cycle'], $board)) + error($config['error']['noaccess']); + + $query = prepare(sprintf('UPDATE ``posts_%s`` SET `cycle` = :cycle WHERE `id` = :id AND `thread` IS NULL', $board)); + $query->bindValue(':id', $post); + $query->bindValue(':cycle', $uncycle ? 0 : 1); + $query->execute() or error(db_error($query)); + if ($query->rowCount()) { + modLog(($uncycle ? 'Made not cyclical' : 'Made cyclical') . " thread #{$post}"); + buildThread($post); + buildIndex(); + } + + header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); +} + function mod_bumplock($board, $unbumplock, $post) { global $config; diff --git a/install.php b/install.php index 5a6512ba..de4d3dd0 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); diff --git a/mod.php b/mod.php index fbe679ac..0378d55c 100644 --- a/mod.php +++ b/mod.php @@ -82,6 +82,7 @@ $pages = array( '/(\%b)/deletebyip/(\d+)(/global)?' => 'secure deletebyip', // delete all posts by IP address '/(\%b)/(un)?lock/(\d+)' => 'secure lock', // lock thread '/(\%b)/(un)?sticky/(\d+)' => 'secure sticky', // sticky thread + '/(\%b)/(un)?cycle/(\d+)' => 'secure cycle', // cycle thread '/(\%b)/bump(un)?lock/(\d+)' => 'secure bumplock', // "bumplock" thread '/themes' => 'themes_list', // manage themes diff --git a/post.php b/post.php index 0d3292bb..98aefe05 100644 --- a/post.php +++ b/post.php @@ -265,7 +265,7 @@ if (isset($_POST['delete'])) { //Check if thread exists if (!$post['op']) { - $query = prepare(sprintf("SELECT `sticky`,`locked`,`sage`,`slug` FROM ``posts_%s`` WHERE `id` = :id AND `thread` IS NULL LIMIT 1", $board['uri'])); + $query = prepare(sprintf("SELECT `sticky`,`locked`,`cycle`,`sage`,`slug` FROM ``posts_%s`` WHERE `id` = :id AND `thread` IS NULL LIMIT 1", $board['uri'])); $query->bindValue(':id', $post['thread'], PDO::PARAM_INT); $query->execute() or error(db_error()); @@ -867,6 +867,15 @@ if (isset($_POST['delete'])) { $post['slug'] = slugify($post); insertFloodPost($post); + + // Handle cyclical threads + if (!$post['op'] && isset($thread['cycle']) && $thread['cycle']) { + // Query is a bit weird due to "This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'" (MariaDB Ver 15.1 Distrib 10.0.17-MariaDB, for Linux (x86_64)) + $query = prepare(sprintf('DELETE FROM ``posts_%s`` WHERE `thread` = :thread AND `id` NOT IN (SELECT `id` FROM (SELECT `id` FROM ``posts_%s`` WHERE `thread` = :thread ORDER BY `id` DESC LIMIT :limit) i)', $board['uri'], $board['uri'])); + $query->bindValue(':thread', $post['thread']); + $query->bindValue(':limit', $config['cycle_limit'], PDO::PARAM_INT); + $query->execute() or error(db_error($query)); + } if (isset($post['antispam_hash'])) { incrementSpamHash($post['antispam_hash']); diff --git a/templates/post/post_controls.html b/templates/post/post_controls.html index 99a8484c..60dc6fc4 100644 --- a/templates/post/post_controls.html +++ b/templates/post/post_controls.html @@ -41,7 +41,6 @@ {% endif %} {% endif %} - {% if mod|hasPermission(config.mod.move, board.uri) %} {% if not post.thread %} {{ config.mod.link_move }}  @@ -49,6 +48,13 @@ {{ config.mod.link_move }}  {% endif %} {% endif %} +{% if mod|hasPermission(config.mod.cycle, board.uri) %} + {% if post.cycle %} + {{ config.mod.link_uncycle }}  + {% else %} + {{ config.mod.link_cycle }}  + {% endif %} +{% endif %} {% if mod|hasPermission(config.mod.editpost, board.uri) %} {{ config.mod.link_editpost }}  {% endif %} diff --git a/templates/post_thread.html b/templates/post_thread.html index 72ca2998..125d5f78 100644 --- a/templates/post_thread.html +++ b/templates/post_thread.html @@ -19,25 +19,32 @@ {{ post.id }} {% if post.sticky %} {% if config.font_awesome %} - + {% else %} Sticky {% endif %} {% endif %} {% if post.locked %} {% if config.font_awesome %} - + {% else %} Locked {% endif %} {% endif %} {% if post.bumplocked and (config.mod.view_bumplock < 0 or (post.mod and post.mod|hasPermission(config.mod.view_bumplock, board.uri))) %} {% if config.font_awesome %} - + {% else %} Bumplocked {% endif %} {% endif %} + {% if post.cycle %} + {% if config.font_awesome %} + + {% else %} + Cyclical + {% endif %} + {% endif %} {% if index %} [{% trans %}Reply{% endtrans %}] {% endif %} diff --git a/templates/posts.sql b/templates/posts.sql index 6b2249ef..070e687b 100644 --- a/templates/posts.sql +++ b/templates/posts.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS ``posts_{{ board }}`` ( `ip` varchar(39) CHARACTER SET ascii NOT NULL, `sticky` int(1) NOT NULL, `locked` int(1) NOT NULL, + `cycle` int(1) NOT NULL, `sage` int(1) NOT NULL, `embed` text, `slug` varchar(256) DEFAULT NULL, -- cgit v1.3 From 5e335a8564a76f0449d47f0767b4baa8d2cb9e91 Mon Sep 17 00:00:00 2001 From: czaks Date: Sun, 14 Aug 2016 16:24:17 +0200 Subject: preliminary inbound nntpchan support --- inc/config.php | 17 +++ install.php | 15 ++- install.sql | 19 +++ post.php | 359 +++++++++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 322 insertions(+), 88 deletions(-) (limited to 'install.php') diff --git a/inc/config.php b/inc/config.php index ebe17572..a8afdf7a 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1689,6 +1689,23 @@ // Example: Adding the pre-markup post body to the API as "com_nomarkup". // $config['api']['extra_fields'] = array('body_nomarkup' => 'com_nomarkup'); +/* + * ================== + * NNTPChan settings + * ================== + */ + + $config['nntpchan'] = array(); + + // Enable NNTPChan integration + $config['nntpchan']['enabled'] = false; + + // NNTP server + $config['nntpchan']['server'] = "localhost:1119"; + + // Global dispatch array. Add your boards to it to enable them. + $config['nntpchan']['dispatch'] = array(); // 'overchan.test' => 'test' + /* * ==================== * Other/uncategorized diff --git a/install.php b/install.php index de4d3dd0..6f1617a8 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. diff --git a/install.sql b/install.sql index bf9b92bf..3b390645 100644 --- a/install.sql +++ b/install.sql @@ -314,6 +314,25 @@ CREATE TABLE `pages` ( UNIQUE KEY `u_pages` (`name`,`board`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; +-- -------------------------------------------------------- + +-- +-- Table structure for table `nntp_references` +-- + +CREATE TABLE `nntp_references` ( + `board` varchar(60) NOT NULL, + `id` int(11) unsigned NOT NULL, + `message_id` varchar(255) CHARACTER SET ascii NOT NULL, + `message_id_digest` varchar(40) CHARACTER SET ascii NOT NULL, + `own` tinyint(1) NOT NULL, + `headers` text, + PRIMARY KEY (`message_id_digest`), + UNIQUE KEY `message_id` (`message_id`), + UNIQUE KEY `u_board_id` (`board`, `id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; + + /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/post.php b/post.php index aa1b8235..4af09928 100644 --- a/post.php +++ b/post.php @@ -11,6 +11,160 @@ if ((!isset($_POST['mod']) || !$_POST['mod']) && $config['board_locked']) { error("Board is locked"); } +$dropped_post = false; + +if (isset($_GET['Newsgroups']) && $config['nntpchan']['enabled']) { + $_POST = array(); + $_POST['json_response'] = true; + + $headers = json_encode($_GET); + + if (!isset ($_GET['Message-Id'])) { + if (!isset ($_GET['Message-ID'])) { + error("NNTPChan: No message ID"); + } + else $msgid = $_GET['Message-ID']; + } + else $msgid = $_GET['Message-Id']; + + $groups = preg_split("/,\s*/", $_GET['Newsgroups']); + if (count($groups) != 1) { + error("NNTPChan: Messages can go to only one newsgroup"); + } + $group = $groups[0]; + + if (!isset($config['nntpchan']['dispatch'][$group])) { + error("NNTPChan: We don't synchronize $group"); + } + $xboard = $config['nntpchan']['dispatch'][$group]; + + $ref = null; + if (isset ($_GET['References'])) { + $refs = preg_split("/,\s*/", $_GET['References']); + + if (count($refs) > 1) { + error("NNTPChan: We don't support multiple references"); + } + + $ref = $refs[0]; + + $query = prepare("SELECT `board`,`id` FROM ``nntp_references`` WHERE `message_id` = :ref"); + $query->bindValue(':ref', $ref); + $query->execute() or error(db_error($query)); + + $ary = $query->fetchAll(PDO::FETCH_ASSOC); + + if (count($ary) == 0) { + error("NNTPChan: We don't have $ref that $msgid references"); + } + + $p_id = $ary[0]['id']; + $p_board = $ary[0]['board']; + + if ($p_board != $xboard) { + error("NNTPChan: Cross board references not allowed. Tried to reference $p_board on $xboard"); + } + + $_POST['thread'] = $p_id; + } + + $date = isset($_GET['Date']) ? strtotime($_GET['Date']) : time(); + + list($ct) = explode('; ', $_GET['Content-Type']); + + $query = prepare("SELECT COUNT(*) AS `c` FROM ``nntp_references`` WHERE `message_id` = :msgid"); + $query->bindValue(":msgid", $msgid); + $query->execute() or error(db_error($query)); + + $a = $query->fetch(PDO::FETCH_ASSOC); + if ($a['c'] > 0) { + error("NNTPChan: We already have this post. Post discarded."); + } + + if ($ct == 'text/plain') { + $content = file_get_contents("php://input"); + } + elseif ($ct == 'multipart/mixed' || $ct == 'multipart/form-data') { + _syslog(LOG_INFO, "MM: Files: ".print_r($GLOBALS, true)); + $content = ''; + + $tmpfiles = $_FILES['attachment']; + foreach ($tmpfiles as $id => $file) { + if ($file['type'] == 'text/plain') { + $content .= file_get_contents($file['tmp_name']); + unset($_FILES['attachment'][$id]); + } + elseif ($file['type'] == 'message/rfc822') { // Signed message, ignore for now + unset($_FILES['attachment'][$id]); + } + else { // A real attachment :^) + } + } + + $_FILES = $_FILES['attachment']; + + + } + else { + error("NNTPChan: Wrong mime type: $ct"); + } + + $_POST['subject'] = isset($_GET['Subject']) ? $_GET['Subject'] : ''; + $_POST['board'] = $xboard; + + if (isset ($_GET['From'])) { + list($name, $mail) = explode(" <", $_GET['From'], 2); + $mail = preg_replace('/>\s+$/', '', $mail); + + $_POST['name'] = $name; + //$_POST['email'] = $mail; + $_POST['email'] = ''; + } + + if (isset ($_GET['X_Sage'])) { + $_POST['email'] = 'sage'; + } + + $content = preg_replace_callback('/>>([0-9a-fA-F]{6,})/', function($id) use ($xboard) { + $id = $id[1]; + + $query = prepare("SELECT `board`,`id` FROM ``nntp_references`` WHERE `message_id_digest` LIKE :rule"); + $idx = $id . "%"; + $query->bindValue(':rule', $idx); + $query->execute() or error(db_error($query)); + + $ary = $query->fetchAll(PDO::FETCH_ASSOC); + if (count($ary) == 0) { + return ">>>>$id"; + } + else { + $ret = array(); + foreach ($ary as $v) { + if ($v['board'] != $xboard) { + $ret[] = ">>>/".$v['board']."/".$v['id']; + } + else { + $ret[] = ">>".$v['id']; + } + } + return implode($ret, ", "); + } + }, $content); + + $_POST['body'] = $content; + + $dropped_post = array( + 'date' => $date, + 'board' => $xboard, + 'msgid' => $msgid, + 'headers' => $headers, + 'from_nntp' => true, + ); +} +elseif (isset($_GET['Newsgroups'])) { + error("NNTPChan: NNTPChan support is disabled"); +} + if (isset($_POST['delete'])) { // Delete @@ -178,8 +332,8 @@ if (isset($_POST['delete'])) { header('Content-Type: text/json'); echo json_encode(array('success' => true)); } -} elseif (isset($_POST['post'])) { - if (!isset($_POST['body'], $_POST['board'])) +} elseif (isset($_POST['post']) || $dropped_post) { + if (!isset($_POST['body'], $_POST['board']) && !$dropped_post) error($config['error']['bot']); $post = array('board' => $_POST['board'], 'files' => array()); @@ -206,61 +360,67 @@ if (isset($_POST['delete'])) { } else $post['op'] = true; - // Check for CAPTCHA right after opening the board so the "return" link is in there - if ($config['recaptcha']) { - if (!isset($_POST['recaptcha_challenge_field']) || !isset($_POST['recaptcha_response_field'])) - error($config['error']['bot']); - // Check what reCAPTCHA has to say... - $resp = recaptcha_check_answer($config['recaptcha_private'], - $_SERVER['REMOTE_ADDR'], - $_POST['recaptcha_challenge_field'], - $_POST['recaptcha_response_field']); - if (!$resp->is_valid) { - error($config['error']['captcha']); + + if (!$dropped_post) { + // Check for CAPTCHA right after opening the board so the "return" link is in there + if ($config['recaptcha']) { + if (!isset($_POST['recaptcha_challenge_field']) || !isset($_POST['recaptcha_response_field'])) + error($config['error']['bot']); + // Check what reCAPTCHA has to say... + $resp = recaptcha_check_answer($config['recaptcha_private'], + $_SERVER['REMOTE_ADDR'], + $_POST['recaptcha_challenge_field'], + $_POST['recaptcha_response_field']); + if (!$resp->is_valid) { + error($config['error']['captcha']); + } } - } - if (!(($post['op'] && $_POST['post'] == $config['button_newtopic']) || - (!$post['op'] && $_POST['post'] == $config['button_reply']))) - error($config['error']['bot']); + if (!(($post['op'] && $_POST['post'] == $config['button_newtopic']) || + (!$post['op'] && $_POST['post'] == $config['button_reply']))) + error($config['error']['bot']); - // Check the referrer - if ($config['referer_match'] !== false && - (!isset($_SERVER['HTTP_REFERER']) || !preg_match($config['referer_match'], rawurldecode($_SERVER['HTTP_REFERER'])))) - error($config['error']['referer']); + // Check the referrer + if ($config['referer_match'] !== false && + (!isset($_SERVER['HTTP_REFERER']) || !preg_match($config['referer_match'], rawurldecode($_SERVER['HTTP_REFERER'])))) + error($config['error']['referer']); - checkDNSBL(); + checkDNSBL(); - // Check if banned - checkBan($board['uri']); + // Check if banned + checkBan($board['uri']); - if ($post['mod'] = isset($_POST['mod']) && $_POST['mod']) { - check_login(false); - if (!$mod) { - // Liar. You're not a mod. - error($config['error']['notamod']); - } + if ($post['mod'] = isset($_POST['mod']) && $_POST['mod']) { + check_login(false); + if (!$mod) { + // Liar. You're not a mod. + error($config['error']['notamod']); + } - $post['sticky'] = $post['op'] && isset($_POST['sticky']); - $post['locked'] = $post['op'] && isset($_POST['lock']); - $post['raw'] = isset($_POST['raw']); + $post['sticky'] = $post['op'] && isset($_POST['sticky']); + $post['locked'] = $post['op'] && isset($_POST['lock']); + $post['raw'] = isset($_POST['raw']); - if ($post['sticky'] && !hasPermission($config['mod']['sticky'], $board['uri'])) - error($config['error']['noaccess']); - if ($post['locked'] && !hasPermission($config['mod']['lock'], $board['uri'])) - error($config['error']['noaccess']); - if ($post['raw'] && !hasPermission($config['mod']['rawhtml'], $board['uri'])) - error($config['error']['noaccess']); - } + if ($post['sticky'] && !hasPermission($config['mod']['sticky'], $board['uri'])) + error($config['error']['noaccess']); + if ($post['locked'] && !hasPermission($config['mod']['lock'], $board['uri'])) + error($config['error']['noaccess']); + if ($post['raw'] && !hasPermission($config['mod']['rawhtml'], $board['uri'])) + error($config['error']['noaccess']); + } + + if (!$post['mod']) { + $post['antispam_hash'] = checkSpam(array($board['uri'], isset($post['thread']) ? $post['thread'] : ($config['try_smarter'] && isset($_POST['page']) ? 0 - (int)$_POST['page'] : null))); + if ($post['antispam_hash'] === true) + error($config['error']['spam']); + } - if (!$post['mod']) { - $post['antispam_hash'] = checkSpam(array($board['uri'], isset($post['thread']) ? $post['thread'] : ($config['try_smarter'] && isset($_POST['page']) ? 0 - (int)$_POST['page'] : null))); - if ($post['antispam_hash'] === true) - error($config['error']['spam']); + if ($config['robot_enable'] && $config['robot_mute']) { + checkMute(); + } } - - if ($config['robot_enable'] && $config['robot_mute']) { - checkMute(); + else { + $mod = $post['mod'] = false; } //Check if thread exists @@ -371,28 +531,36 @@ if (isset($_POST['delete'])) { $post['email'] = str_replace(' ', '%20', htmlspecialchars($_POST['email'])); $post['body'] = $_POST['body']; $post['password'] = $_POST['password']; - $post['has_file'] = (!isset($post['embed']) && (($post['op'] && !isset($post['no_longer_require_an_image_for_op']) && $config['force_image_op']) || !empty($_FILES['file']['name']))); + $post['has_file'] = (!isset($post['embed']) && (($post['op'] && !isset($post['no_longer_require_an_image_for_op']) && $config['force_image_op']) || count($_FILES) > 0)); - if (!($post['has_file'] || isset($post['embed'])) || (($post['op'] && $config['force_body_op']) || (!$post['op'] && $config['force_body']))) { - $stripped_whitespace = preg_replace('/[\s]/u', '', $post['body']); - if ($stripped_whitespace == '') { - error($config['error']['tooshort_body']); + if (!$dropped_post) { + + if (!($post['has_file'] || isset($post['embed'])) || (($post['op'] && $config['force_body_op']) || (!$post['op'] && $config['force_body']))) { + $stripped_whitespace = preg_replace('/[\s]/u', '', $post['body']); + if ($stripped_whitespace == '') { + error($config['error']['tooshort_body']); + } } - } - if (!$post['op']) { - // Check if thread is locked - // but allow mods to post - if ($thread['locked'] && !hasPermission($config['mod']['postinlocked'], $board['uri'])) - error($config['error']['locked']); + if (!$post['op']) { + // Check if thread is locked + // but allow mods to post + if ($thread['locked'] && !hasPermission($config['mod']['postinlocked'], $board['uri'])) + error($config['error']['locked']); - $numposts = numPosts($post['thread']); + $numposts = numPosts($post['thread']); - if ($config['reply_hard_limit'] != 0 && $config['reply_hard_limit'] <= $numposts['replies']) - error($config['error']['reply_hard_limit']); + if ($config['reply_hard_limit'] != 0 && $config['reply_hard_limit'] <= $numposts['replies']) + error($config['error']['reply_hard_limit']); - if ($post['has_file'] && $config['image_hard_limit'] != 0 && $config['image_hard_limit'] <= $numposts['images']) - error($config['error']['image_hard_limit']); + if ($post['has_file'] && $config['image_hard_limit'] != 0 && $config['image_hard_limit'] <= $numposts['images']) + error($config['error']['image_hard_limit']); + } + } + else { + if (!$post['op']) { + $numposts = numPosts($post['thread']); + } } if ($post['has_file']) { @@ -442,7 +610,7 @@ if (isset($_POST['delete'])) { $trip = generate_tripcode($post['name']); $post['name'] = $trip[0]; - $post['trip'] = isset($trip[1]) ? $trip[1] : ''; + $post['trip'] = isset($trip[1]) ? $trip[1] : ''; // XX: Dropped posts and tripcodes $noko = false; if (strtolower($post['email']) == 'noko') { @@ -477,15 +645,17 @@ if (isset($_POST['delete'])) { if (empty($post['files'])) $post['has_file'] = false; - // Check for a file - if ($post['op'] && !isset($post['no_longer_require_an_image_for_op'])) { - if (!$post['has_file'] && $config['force_image_op']) - error($config['error']['noimage']); - } + if (!$dropped_post) { + // Check for a file + if ($post['op'] && !isset($post['no_longer_require_an_image_for_op'])) { + if (!$post['has_file'] && $config['force_image_op']) + error($config['error']['noimage']); + } - // Check for too many files - if (sizeof($post['files']) > $config['max_images']) - error($config['error']['toomanyimages']); + // Check for too many files + if (sizeof($post['files']) > $config['max_images']) + error($config['error']['toomanyimages']); + } if ($config['strip_combining_chars']) { $post['name'] = strip_combining_chars($post['name']); @@ -494,18 +664,19 @@ if (isset($_POST['delete'])) { $post['body'] = strip_combining_chars($post['body']); } - // Check string lengths - if (mb_strlen($post['name']) > 35) - error(sprintf($config['error']['toolong'], 'name')); - if (mb_strlen($post['email']) > 40) - error(sprintf($config['error']['toolong'], 'email')); - if (mb_strlen($post['subject']) > 100) - error(sprintf($config['error']['toolong'], 'subject')); - if (!$mod && mb_strlen($post['body']) > $config['max_body']) - error($config['error']['toolong_body']); - if (mb_strlen($post['password']) > 20) - error(sprintf($config['error']['toolong'], 'password')); - + if (!$dropped_post) { + // Check string lengths + if (mb_strlen($post['name']) > 35) + error(sprintf($config['error']['toolong'], 'name')); + if (mb_strlen($post['email']) > 40) + error(sprintf($config['error']['toolong'], 'email')); + if (mb_strlen($post['subject']) > 100) + error(sprintf($config['error']['toolong'], 'subject')); + if (!$mod && mb_strlen($post['body']) > $config['max_body']) + error($config['error']['toolong_body']); + if (mb_strlen($post['password']) > 20) + error(sprintf($config['error']['toolong'], 'password')); + } wordfilters($post['body']); $post['body'] = escape_markup_modifiers($post['body']); @@ -514,6 +685,7 @@ if (isset($_POST['delete'])) { $post['body'] .= "\n1"; } + if (!$dropped_post) if (($config['country_flags'] && !$config['allow_no_country']) || ($config['country_flags'] && $config['allow_no_country'] && !isset($_POST['no_country']))) { require 'inc/lib/geoip/geoip.inc'; $gi=geoip\geoip_open('inc/lib/geoip/GeoIPv6.dat', GEOIP_STANDARD); @@ -555,6 +727,7 @@ if (isset($_POST['delete'])) { $post['body'] .= "\n" . $_POST['tag'] . ""; } + if (!$dropped_post) if ($config['proxy_save'] && isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $proxy = preg_replace("/[^0-9a-fA-F.,: ]/", '', $_SERVER['HTTP_X_FORWARDED_FOR']); $post['body'] .= "\n".$proxy.""; @@ -627,7 +800,7 @@ if (isset($_POST['delete'])) { } } - if (!hasPermission($config['mod']['bypass_filters'], $board['uri'])) { + if (!hasPermission($config['mod']['bypass_filters'], $board['uri']) && !$dropped_post) { require_once 'inc/filters.php'; do_filters($post); @@ -830,11 +1003,11 @@ if (isset($_POST['delete'])) { } // Do filters again if OCRing - if ($config['tesseract_ocr'] && !hasPermission($config['mod']['bypass_filters'], $board['uri'])) { + if ($config['tesseract_ocr'] && !hasPermission($config['mod']['bypass_filters'], $board['uri']) && !$dropped_post) { do_filters($post); } - if (!hasPermission($config['mod']['postunoriginal'], $board['uri']) && $config['robot_enable'] && checkRobot($post['body_nomarkup'])) { + if (!hasPermission($config['mod']['postunoriginal'], $board['uri']) && $config['robot_enable'] && checkRobot($post['body_nomarkup']) && !$dropped_post) { undoImage($post); if ($config['robot_mute']) { error(sprintf($config['error']['muted'], mute())); @@ -873,6 +1046,18 @@ if (isset($_POST['delete'])) { $post['id'] = $id = post($post); $post['slug'] = slugify($post); + + if ($dropped_post && $dropped_post['from_nntp']) { + $query = prepare("INSERT INTO ``nntp_references`` (`board`, `id`, `message_id`, `message_id_digest`, `own`, `headers`) VALUES ". + "(:board , :id , :message_id , :message_id_digest , false, :headers)"); + + $query->bindValue(':board', $dropped_post['board']); + $query->bindValue(':id', $id); + $query->bindValue(':message_id', $dropped_post['msgid']); + $query->bindValue(':message_id_digest', sha1($dropped_post['msgid'])); + $query->bindValue(':headers', $dropped_post['headers']); + $query->execute() or error(db_error($query)); + } insertFloodPost($post); // Handle cyclical threads -- cgit v1.3