diff --git a/srv/index.php b/srv/index.php
new file mode 100644
index 0000000..1b78050
--- /dev/null
+++ b/srv/index.php
@@ -0,0 +1,4384 @@
+Redirection in about 3 secs.";
+ $print_string .= " If that does not work, go";
+ $print_string .= " back.";
+
+ echo "$print_string";
+}
+
+// Translate simple bbcode to html, and highlight quotes, like so:
+// [b bold],[i italic],[u underlined],[s strikethrough]
+// [h headline],[sp spoiler],[li list element],[url link],>>quote\r\n
+function bbcode_to_html($text, $settings)
+{
+ if ( ($settings['enable_bbcode'] == FALSE) ) {
+ return $text;
+ }
+
+ $search = array (
+ '/(\[b\ )(.*)(\])/',
+ '/(\[i\ )(.*)(\])/',
+ '/(\[u\ )(.*)(\])/',
+ '/(\[s\ )(.*)(\])/',
+ '/(\[h\ )(.*)(\])/',
+ '/(\[sp\ )(.*)(\])/',
+ '/(\[li\ )(.*)(\])/',
+ '/(\[url\ )(.*)(\])/',
+ '/>>(.*)\r\n/'
+ );
+
+ $replace = array (
+ '$2',
+ '$2',
+ '$2',
+ '$2',
+ '
$2
',
+ '$2',
+ '$2',
+ '$2',
+ '>>$1$2
'
+ );
+
+ return preg_replace($search, $replace, $text);
+
+}
+
+// checks if the bot trap has been called recently from the ip.
+// according to the parameters in the config file, the request is then
+// either granted or blocked.
+// if the blocking of tor is enabled, 127.0.0.1 will be included in the
+// blocking, which can mean that no connections from tor or local are taken
+// during the block time.
+function bot_block($db, $settings, $ip)
+{
+ if ( ($settings['enable_bot_block'] != TRUE) ) {
+ return;
+ }
+
+ if ( ($settings['enable_tor_block'] != TRUE)
+ && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') ) {
+ return;
+ }
+
+ $current = time();
+ $max_age = $current - ($settings['block_time'] * 60);
+ // max age is in minutes, so times 60 to go to seconds
+
+ if ($settings['superstrict_block'] == TRUE) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'bot'
+ AND ip = '$ip'
+ AND event in ('Level 2',
+ 'Level 1',
+ '429')
+ AND unix_timestamp > '$max_age'");
+ } else {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'bot'
+ AND ip = '$ip'
+ AND event in ('Level 2',
+ 'Level 1')
+ AND unix_timestamp > '$max_age'");
+ }
+
+ $result = $statement->execute();
+
+ $trap_visits = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $trap_visits++;
+ }
+
+ if ( ($trap_visits > $settings['max_trap_visits']) ) {
+ $bot_block_message = "429";
+ log_event($db, $settings, "bot", $bot_block_message, $ip);
+ header("HTTP/1.1 429 Too Many Requests");
+ quit($db, "429");
+ }
+
+ if ( ($settings['max_landing'] > 0)
+ && ($settings['superstrict_block'] == TRUE) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'bot'
+ AND ip = '$ip'
+ AND event in ('landing page bot request',
+ '429')
+ AND unix_timestamp > '$max_age'");
+ } elseif ( ($settings['max_landing'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'bot'
+ AND ip = '$ip'
+ AND event = 'landing page bot request'
+ AND unix_timestamp > '$max_age'");
+ } else {
+ return;
+ }
+
+ $result = $statement->execute();
+
+ $landing_visits = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $landing_visits++;
+ }
+
+ if ( ($landing_visits > $settings['max_landing']) ) {
+ $bot_block_message = "429";
+ log_event($db, $settings, "bot", $bot_block_message, $ip);
+ header("HTTP/1.1 429 Too Many Requests");
+ quit($db, "429");
+ }
+
+ return;
+}
+// receive a message from bot, check if it's ok to post, and do it (or quit)
+function bot_me($db, $settings)
+{
+
+ $json_data = json_decode(file_get_contents('php://input'), TRUE);
+
+ if ( (!isset($json_data['text'])) || (!isset($json_data['sub'])) ) {
+ header("HTTP/1.1 400 Bad Request");
+ quit($db,"400");
+ }
+
+ $sub = substr(preg_replace("/[^0-9a-zA-Z]/", "", $json_data['sub']),
+ 0, $settings['max_name_sub']);
+
+ if (!in_array($sub, $settings['anonymous_bot_subs'])) {
+ if (isset($json_data['key'])) {
+ $key = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $json_data['key']), 0, 20);
+ // 20 chars is enough for a bot key
+ if (!in_array($key, $settings['bot_keys'])) {
+ $auth_message = "bot used wrong key";
+ log_event($db, $settings, "auth", $auth_message, '');
+ sleep(10);
+ header("HTTP/1.1 401 Unauthorized");
+ quit($db,"401");
+ }
+ } else {
+ header("HTTP/1.1 400 Bad Request");
+ quit($db,"400");
+ }
+ }
+
+ if (isset($json_data['org_id'])) {
+ $org_id = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $json_data['org_id']), 0, $settings['max_name_sub']);
+ } else {
+ $org_id = '';
+ }
+
+ $text = strip_tags($json_data['text']);
+ check_spam($db, $text, $settings);
+ $text_id = hash("sha512", $text);
+
+ if ( (check_original_content
+ ($db, $settings, $sub, $text_id, $org_id) == FALSE) ) {
+ header("HTTP/1.1 403 Forbidden");
+ $content_message = "This text has been posted before, the admin";
+ $content_message .= " requests original content.";
+ quit($db, $content_message);
+ }
+
+ make_post($db, $sub, $settings, $text, $org_id);
+}
+
+// If the post is a reply, put the original post on top.
+function bump_message($db, $org_id, $sub)
+{
+
+ $statement = $db->prepare("SELECT text, global_id, text_id
+ FROM threads
+ WHERE post_id = '$org_id'
+ AND org_id = '$org_id'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $text = "{$row[0]}";
+ $global_id = "{$row[1]}";
+ $text_id = "{$row[2]}";
+ }
+
+ $statement = $db->prepare("DELETE FROM threads
+ WHERE post_id = '$org_id'
+ AND org_id = '$org_id'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ $statement = $db->prepare("INSERT INTO threads(post_id, sub, text,
+ org_id, shadow,
+ global_id, text_id)
+ VALUES ('$org_id', '$sub', ?, '$org_id',
+ 'no', '$global_id', '$text_id')");
+ $statement->bindParam(1, $text);
+
+ $statement->execute();
+
+}
+
+// Break text according to config.php, also transform \r\n to
+function break_text($text, $settings)
+{
+
+ $post_text = wordwrap($text, $settings['line_break'], "\n", TRUE);
+ $post_text = nl2br($post_text, FALSE);
+ return $post_text;
+
+}
+
+// Lets the admin change the status of a mod or an applicant
+function change_mods($db, $css, $settings, $token)
+{
+
+ if ( (isset($_POST['disable_mod'])) ) {
+ $mod = strip_tags($_POST['disable_mod']);
+ $statement = $db->prepare("UPDATE keys
+ SET type = 'disabled'
+ WHERE name = '$mod'");
+ $log_message = "Moderator account $mod was disabled";
+ } elseif ( (isset($_POST['enable_mod'])) ) {
+ $mod = strip_tags($_POST['enable_mod']);
+ $statement = $db->prepare("UPDATE keys
+ SET type = 'mod'
+ WHERE name = '$mod'");
+ $log_message = "Moderator account $mod was enabled";
+ } elseif ( (isset($_POST['delete_mod'])) ) {
+ $mod = strip_tags($_POST['delete_mod']);
+ $statement = $db->prepare("DELETE FROM keys
+ WHERE name = '$mod'");
+ $log_message = "Moderator account $mod was deleted";
+ }
+
+ $result = $statement->execute();
+
+ log_event($db, $settings, "sys", $log_message, '');
+
+}
+
+// Check if the password for the admin is set or not.
+// If it was not done already, generate token and write to db and the
+// file /var/opt/endboard/admin_$name_token.txt
+function check_admin($db, $settings)
+{
+
+ if ( ($settings['admin'] == 'change-me')
+ || ($settings['admin'] == '') ) {
+ $config_file = $settings['config_file'];
+ $admin_message = "The name of the admin is not updated ";
+ $admin_message .= "in the configfile, or it is empty. Set ";
+ $admin_message .= "the variable \$admin in the file ";
+ $admin_message .= " $config_file and retry.
";
+ quit($db, $admin_message);
+ }
+
+ $admin = $settings['admin'];
+ $filename = $settings['work_dir'] . "admin_" . $admin . "_token.txt";
+
+ $statement = $db->prepare("SELECT key FROM keys
+ WHERE type = 'admin'
+ AND name = '$admin'");
+ $result = $statement->execute();
+
+ $key = '';
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $key = "{$row[0]}";
+ $counter++;
+ }
+
+ if ( ($key == '') && (!file_exists($filename)) ) {
+ $token = make_token();
+ $token_hash = password_hash($token, PASSWORD_DEFAULT);
+
+ if ($counter == 0) {
+ $statement = $db->prepare("INSERT INTO keys(token, type, name)
+ VALUES ('$token_hash',
+ 'admin',
+ '$admin')");
+ } else {
+ $statement = $db->prepare("UPDATE keys
+ SET token = '$token_hash'
+ WHERE name = '$admin'
+ AND type = 'admin'");
+ }
+
+ $result = $statement->execute();
+
+ $content = "Token = \r\n$token\r\n";
+ file_put_contents($filename, $content);
+
+ return FALSE;
+ } elseif ( ($key == '') ) {
+ return FALSE;
+ } else {
+ return TRUE;
+ }
+
+}
+
+// Check if access to the admin panel is enabled in the config file.
+function check_admin_panel($db, $settings)
+{
+
+ if ($settings['enable_admin_panel'] != TRUE) {
+ header("HTTP/1.1 403 Forbidden");
+ quit($db, "403");
+ }
+
+}
+
+// Checks if the number of moderators applications is more than 10.
+function check_application_count($db)
+{
+
+ $statement = $db->prepare("SELECT name
+ FROM keys
+ WHERE type = 'application'");
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ($counter > 10) {
+ // 10 seems reasonable in order not to loose the overview
+ return FALSE;
+ } else {
+ return TRUE;
+ }
+}
+
+// Check if access to the admin panel and its functions can be given
+// or not. First, a quick check if there is an admin defined, and if
+// the default name was changed. Second, the token is checked, if there
+// is one. If there is none, user/password is checked against the db.
+// If the login is successful, a token is created and given back.
+function check_auth_admin($db, $settings)
+{
+
+ $token = read_pretty_vars('last', 'alnum', 250);
+ // The length of the token is 250 characters
+
+ if ( (mb_strlen($token) !== 250) ) {
+ // The length of the token is 250 characters
+ $token = get_post_token();
+ }
+
+ if ( (mb_strlen($token) === 250) ) {
+ // The length of the token is 250 characters
+ $statement = $db->prepare("SELECT timestamp_token, token
+ FROM keys
+ WHERE type = 'admin'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $timestamp_token = "{$row[0]}";
+ $access_token = "{$row[1]}";
+ }
+
+ $current = time();
+ $max_age = $current - ($settings['lifetime_token'] * 60);
+ // max lifetime is defined in minutes, times 60 to go to seconds
+
+ if ( ($timestamp_token < $max_age) ) {
+ $auth_message = "outdated token used";
+ log_event($db, $settings, "auth", $auth_message, '');
+ $quit_message = "Token expired.Log in ";
+ $quit_message .= "again to get a new one.";
+ $quit_message .= "This incident was logged.
";
+ quit($db, $quit_message);
+ }
+
+ if ( (!password_verify($token, $access_token)) ) {
+ $auth_message = "invalid token used";
+ log_event($db, $settings, "auth", $auth_message, '');
+ sleep(10);
+ $quit_message = "Invalid token used.";
+ $quit_message .= "This incident was logged.
";
+ quit($db, $quit_message);
+ }
+
+ } elseif ( (!isset($_POST['auth_name']))
+ || (!isset($_POST['auth_password'])) ) {
+ $quit_message = "Please provide the name and";
+ $quit_message .= " password for your account.
";
+ quit($db, $quit_message);
+ } else {
+ $auth_name = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $_POST['auth_name']), 0, 50);
+ // 50 seems to be a generous for the length of the username
+ $statement = $db->prepare("SELECT key
+ FROM keys
+ WHERE type = 'admin'
+ AND name = '$auth_name'");
+ $result = $statement->execute();
+
+ $key = '';
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $key = "{$row[0]}";
+ }
+
+ if ( (!password_verify($_POST['auth_password'], $key)) ) {
+ $auth_message = "wrong combination user/password";
+ log_event($db, $settings, "auth", $auth_message, '');
+ sleep(10);
+ $quit_message = "Combination of password and username ";
+ $quit_message .= "is invalid. This incident was logged.
";
+ quit($db, $quit_message);
+ }
+
+ $token = make_token();
+ $token_hash = password_hash($token, PASSWORD_DEFAULT);
+
+ $current = time();
+
+ $statement = $db->prepare("UPDATE keys
+ SET timestamp_token = '$current',
+ token = '$token_hash'
+ WHERE name = '$auth_name'
+ AND type = 'admin'");
+ $result = $statement->execute();
+
+ $auth_message = "admin $auth_name logged on";
+ log_event($db, $settings, "auth", $auth_message, '');
+ }
+
+ return $token;
+}
+
+// Check if access to the mod panel and can be given or not.
+// The token is checked, if there is one. If there is none,
+// user/password is checked against the db.
+// If the login is successful, a token is created and given back.
+function check_auth_mod($db, $settings)
+{
+
+ $token = read_pretty_vars('last', 'alnum', 250);
+ // the length of the token is defined to 250 characters, so that it
+ // cannot be memorized,
+ // and so that it does never show fully in the addressbar
+
+ if ( (mb_strlen($token) !== 250) ) {
+ // token length = 250
+ $token = get_post_token();
+ }
+
+ if ( (mb_strlen($token) === 250) ) {
+ // token length = 250
+ $statement = $db->prepare("SELECT timestamp_token, token
+ FROM keys
+ WHERE type = 'mod'");
+ $result = $statement->execute();
+
+ $found_token = FALSE;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $timestamp_token = "{$row[0]}";
+ $access_token = "{$row[1]}";
+ if ( (password_verify($token, $access_token)) ) {
+ $found_token = TRUE;
+ break;
+ }
+ }
+
+ if ( ($found_token == FALSE) ) {
+ $auth_message = "invalid token used";
+ log_event($db, $settings, "auth", $auth_message, '');
+ quit($db, "Token invalid. This incident was logged.
");
+ }
+
+ $current = time();
+ $max_age = $current - ($settings['lifetime_token'] * 60);
+ // lifetime of token is in minutes, times 60 to go to seconds
+
+ if ($timestamp_token < $max_age) {
+ $auth_message = ("outdated token used");
+ log_event($db, $settings, "auth", $auth_message, '');
+ $quit_message = "Token expired.Log in";
+ $quit_message .= " again to get a new one. ";
+ $quit_message .= "This incident was logged.
";
+ quit($db, $quit_message);
+ }
+ } elseif ( (!isset($_POST['auth_name']))
+ || (!isset($_POST['auth_password'])) ) {
+ $quit_message = "Please provide the name";
+ $quit_message .= " and password for your account.
";
+ quit($db, $quit_message);
+ } else {
+ $auth_name = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $_POST['auth_name']), 0, 50);
+ // 50 seems to be a generous for the length of the username
+ $statement = $db->prepare("SELECT key FROM keys
+ WHERE type = 'mod'
+ AND name = '$auth_name'");
+ $result = $statement->execute();
+
+ $key = '';
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $key = "{$row[0]}";
+ }
+
+ if ( (!password_verify($_POST['auth_password'], $key)) ) {
+ $auth_message = "wrong combination user/password";
+ log_event($db, $settings, "auth", $auth_message, '');
+ sleep(10);
+ $quit_message = "Combination of password and username is";
+ $quit_message .= " invalid. This incident was logged.
";
+ quit($db, $quit_message);
+ }
+
+ $token = make_token();
+ $token_hash = password_hash($token, PASSWORD_DEFAULT);
+
+ $current = time();
+
+ $statement = $db->prepare("UPDATE keys
+ SET timestamp_token = '$current',
+ token = '$token_hash'
+ WHERE name = '$auth_name'");
+ $result = $statement->execute();
+
+ $auth_message = "mod $auth_name logged on";
+ log_event($db, $settings, "auth", $auth_message, '');
+ }
+
+ return $token;
+}
+
+// Check the hashed captcha against the hashed solutions in the db.
+function check_captcha($db, $settings)
+{
+ if ( (!isset($_POST['post_token'])) ) {
+ quit($db, "What are you up to ? Use the postform.
");
+ }
+
+ if ( ($settings['use_captcha'] == FALSE) ){
+ $post_hash = hash("sha512", $_POST['post_token']);
+ } elseif ( (!isset($_POST['math_one']))
+ || (!isset($_POST['math_two']))
+ || (!isset($_POST['math_type']))
+ || (!isset($_POST['math_answer'])) ) {
+ quit($db, "What are you up to ? Use the postform.
");
+ } else {
+ $post_summary = ($_POST['math_one'] . $_POST['math_two'] .
+ $_POST['math_type'] . $_POST['math_answer'] .
+ $_POST['post_token']);
+ $post_hash = hash("sha512", $post_summary);
+ }
+
+ $current = time();
+ $max_age = $current - $settings['lifetime_captcha'] * 60 * 60;
+ // lifetime is in hours in the configfile,
+ // so times 60 * 60 to go to seconds
+
+ $statement = $db->prepare("SELECT hash, unix_timestamp
+ FROM captchas
+ WHERE hash = '$post_hash'
+ AND unix_timestamp > '$max_age'");
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($settings['use_captcha'] == FALSE) && ($counter < 1) ) {
+ $quit_message = "Unauthorized attempt to post.";
+ $quit_message .= "Use a newly opened postform.
";
+ quit($db, $quit_message);
+ } elseif ( ($counter < 1) ) {
+ quit($db, "wrong answer or captcha expired, try again
");
+ } else {
+ $statement = $db->prepare("DELETE FROM captchas
+ WHERE hash = '$post_hash'");
+ $result = $statement->execute();
+ }
+}
+
+// Check if we have enough free space on the harddisk to allow new posts
+function check_free_space($db, $settings)
+{
+
+ $free_space = disk_free_space("/");
+
+ if ($free_space < ($settings['min_space'] * 1024 * 1024)) {
+ // the setting is in Megabyte, free space operates in bytes
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+// Check if the maximum requests as defined in the config file are
+// exhausted or not
+function check_max_requests($db, $settings, $ip)
+{
+
+ $current = time();
+
+ if ( ($settings['max_requests_ip'] > 0)
+ && ($_SERVER['REMOTE_ADDR'] != '127.0.0.1') ) {
+ $max_age = $current - ($settings['max_requests_timeframe'] * 60);
+ // max age is in minutes, so times 60 to go to seconds
+ $max_visits = $settings['max_requests_ip'];
+ } elseif ( ($settings['max_requests_tor'] > 0)
+ && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') ) {
+ $max_age = $current - ($settings['max_requests_tor_timeframe'] * 60);
+ // max age is in minutes, so times 60 to go to seconds
+ $max_visits = $settings['max_requests_tor'];
+ } else {
+ return;
+ }
+
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'portal'
+ AND ip = '$ip'
+ AND event in ('visit')
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $visits = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $visits++;
+ }
+
+ if ( ($visits > $max_visits) ) {
+ $block_message = "429";
+ log_event($db, $settings, "user", $block_message, $ip);
+ header("HTTP/1.1 429 Too Many Requests");
+ quit($db, "429");
+ }
+}
+
+// Check if access to the mod panel is enabled in the config file.
+function check_mod_panel($db, $settings)
+{
+
+ if ($settings['enable_mod_panel'] != TRUE) {
+ header("HTTP/1.1 403 Forbidden");
+ quit($db, "403");
+ }
+
+}
+
+// checks if content has been posted before, according the config file.
+function check_original_content($db, $settings, $sub, $text_id, $org_id)
+{
+ $statement = $db->prepare("SELECT post_id, sub, org_id
+ FROM threads
+ WHERE text_id = '$text_id'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $result_post_id = "{$row[0]}";
+ $result_sub = "{$row[1]}";
+ $result_org_id = "{$row[2]}";
+ if ( ($settings['original_content_global'] == TRUE) ){
+ return FALSE;
+ } elseif ( ($settings['original_content_sub'] == TRUE)
+ && ($sub == $result_sub) ){
+ return FALSE;
+ } elseif ( ($settings['original_content_thread'] == TRUE)
+ && ($sub == $result_sub)
+ && ($org_id == $result_org_id) ){
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+// Check if a message actually exists in the database when replying.
+// Note that if the links of the site are used for navigation, this is
+// always the case (unless it was deleted meanwhile).
+// This routine is mostly to prevent malicious users from creating
+// ghost messages that are in the db but are never displayed
+// (because the threadstart is missing).
+function check_org_id_exists($db, $sub, $org_id)
+{
+
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'
+ AND post_id = '$org_id'");
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter < 1) ) {
+ // if the counter is smaller 1, there is no match
+ return FALSE;
+ } else {
+ return TRUE;
+ }
+}
+
+// check if the ip has already passed the portal, in this case return.
+// if not, display a simple text and button to click to proceed.
+// the page displayed is done with inline styling, so that no
+// additional files will be requested.
+// Update: what started with the checking of the ip, has now expanded to
+// up to six parameters, which are concantenated and hashed.
+function check_portal($db, $settings, $ip)
+{
+ if ( ($settings['enable_portal'] != TRUE) ) {
+ return;
+ }
+
+ if ( ($settings['enable_portal_tor'] != TRUE)
+ && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') ) {
+ return;
+ }
+
+ $current = time();
+ $max_age = $current - ($settings['portal_lifetime'] * 60);
+ // lifetime is in minutes, so times 60 to go to seconds
+
+ if ($settings['auto_prolong_portal'] == TRUE) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'portal'
+ AND ip = '$ip'
+ AND event in ('pass',
+ 'visit')
+ AND unix_timestamp > '$max_age'
+ ORDER BY ROWID DESC LIMIT 1");
+ } else {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'portal'
+ AND ip = '$ip'
+ AND event in ('pass')
+ AND unix_timestamp > '$max_age'
+ ORDER BY ROWID DESC LIMIT 1");
+ }
+
+ $result = $statement->execute();
+
+ $portal_pass = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $portal_pass++;
+ }
+
+ if ( ($portal_pass > 0) ) {
+ // bigger zero means we have the last hit, meaning the ip
+ // is known
+ $portal_message = "visit";
+ log_event($db, $settings, "portal", $portal_message, $ip);
+ return;
+ } else {
+ $request = $_SERVER['REQUEST_URI'];
+ header("HTTP/1.1 202 Accepted");
+ $html_string = "
";
+ $html_string .= "Entry portal: ";
+ $html_string .= "Please click the button to proceed.
";
+ $html_string .= "
";
+ $html_string .= "
";
+ $html_string .= "
Sorry for this,";
+ $html_string .= " it's just a lowlevel protection ";
+ $html_string .= "against scraping bots.
";
+ echo "$html_string";
+ quit($db, '');
+ }
+}
+
+// Check if a message exists already in the database when importing.
+function check_post_exists($db, $sub, $org_id, $post_id)
+{
+
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE sub = '$sub'
+ AND org_id = '$org_id'
+ AND post_id = '$post_id'");
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter < 1) ) {
+ // if the counter is smaller 1, there is no match
+ return FALSE;
+ } else {
+ return TRUE;
+ }
+}
+
+// A simple check if the post is spam or not, also if it is too long
+// or too short.
+// These are a few of the only original lines of code left from smolBBS.
+function check_spam($db, $text, $settings)
+{
+
+ if (preg_match('/^(.)\1*$/u ', $text)) {
+ quit($db, "Spam detected!
");
+ }
+
+ $post_length = strlen($text);
+
+ if ($post_length < $settings['min_char']) {
+ quit($db, "Post too short!
");
+ }
+
+ if ($post_length > $settings['max_char']) {
+ quit($db, "Post too long!
");
+ }
+
+ $text = str_replace(array("\n","\r"), '', $text);
+
+ if (substr_count($text, ' ') === strlen($text)) {
+ quit($db, "Spam detected! Post contained only spaces!
");
+ }
+//rewrite, does not work for all cases
+ if (ctype_space($text)) {
+ $quit_message = "Spam detected! Post contained only spaces ";
+ $quit_message .= "(yeah, Unicode...)!
";
+ quit($db, $quit_message);
+ }
+
+}
+
+// Check if a sub exists before display
+// If the name does not exist, name* is searched
+function check_sub_exists($db, $sub)
+{
+
+ if ( ($sub == 'overboard') || ($sub == 'main') ) {
+ return $sub;
+ }
+
+ $statement = $db->prepare("SELECT sub
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ $sub = "{$row[0]}";
+ }
+
+ if ( ($counter < 1) ) {
+ // if the counter is smaller 1, there is no match
+ $statement = $db->prepare("SELECT sub
+ FROM threads
+ WHERE sub GLOB '$sub*'
+ AND shadow = 'no'
+ ORDER BY sub
+ ASC LIMIT 1");
+
+ $result = $statement->execute();
+
+ $counter_2 = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter_2++;
+ $sub = "{$row[0]}";
+ }
+
+ if ( ($counter_2 < 1) ) {
+ // if the counter is smaller 1, there is no match
+ return FALSE;
+ } else {
+ return $sub;
+ }
+
+ } else {
+ return $sub;
+ }
+}
+
+// record $_SERVER info
+
+function debug_server($db, $settings)
+
+{
+
+
+
+ $filename = $settings['work_dir'] . 'debug_server.txt';
+
+ $content = print_r($_SERVER, TRUE);
+
+ file_put_contents($filename, $content, FILE_APPEND | LOCK_EX);
+
+
+
+}
+
+
+// Delete selected logs
+function delete_logs($db, $type, $token)
+{
+
+ if ($type == 'all') {
+ $statement = $db->prepare("DELETE FROM logs");
+ $delete_message = "all logs were deleted";
+ } else {
+ $statement = $db->prepare("DELETE FROM logs
+ WHERE type = '$type'");
+ $delete_message = "logs of type $type were deleted";
+ }
+
+ $result = $statement->execute();
+
+ echo "$delete_message";
+}
+
+// Delete one post or a range of posts.
+// If you use php8.* you can change to str_contains below.
+function delete_posts($db, $sub, $settings, $token, $delete_mode)
+{
+//rewrite to check if post exists first,
+// and only move it if it is not shadowed
+ if ($delete_mode == 'shadow') {
+ $posts = strip_tags($_POST['shadow_posts']);
+ } elseif ($delete_mode == 'move') {
+ $posts = strip_tags($_POST['shadow_posts']);
+ $target_sub = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $_POST['target_sub']), 0,
+ $settings['max_name_sub']);
+ if ( (check_sub_exists($db, $target_sub) != TRUE) ) {
+ quit($db, "Sub $target_sub does not exist.
");
+ }
+ } else {
+ $posts = strip_tags($_POST['delete_posts']);
+ }
+
+// if (str_contains($posts, '-')) {
+// only uncomment if you use php8.*
+ if (strpos($posts, '-') !== FALSE) {
+ $delete_minmax = explode('-', $posts);
+ $delete_array = array();
+ for($delete = $delete_minmax['0'];
+ $delete <= $delete_minmax['1']; $delete++) {
+ array_push($delete_array, $delete);
+ }
+ } else {
+ $delete_array = explode(' ', $posts);
+ }
+
+ $statement = $db->prepare("SELECT name, token
+ FROM keys
+ WHERE type = 'mod'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $name = "{$row[0]}";
+ $access_token = "{$row[1]}";
+ if ( (password_verify($token, $access_token)) ) {
+ break;
+ }
+ }
+
+ $html_string = '';
+
+ if ( (!empty($delete_array)) && ($delete_mode == 'delete') ) {
+
+ foreach($delete_array as $delete_post) {
+ $statement = $db->prepare("DELETE FROM threads
+ WHERE post_id = '$delete_post'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ $delete_message = "message $delete_post from ";
+ $delete_message .= "sub $sub deleted by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+ $html_string .= "$delete_message
";
+
+ }
+
+ } elseif ( (!empty($delete_array)) && ($delete_mode == 'shadow') ) {
+
+ foreach($delete_array as $delete_post) {
+ $statement = $db->prepare("UPDATE threads
+ SET shadow = 'yes'
+ WHERE post_id = '$delete_post'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ $delete_message = "message $delete_post from ";
+ $delete_message .= "sub $sub shadowed by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ $html_string .= "$delete_message
";
+ }
+
+ } elseif ( (!empty($delete_array))
+ && ($delete_mode == 'move') ) {
+
+ move_post($db, $settings, $delete_array, $sub, $target_sub, $name);
+
+ }
+
+ $html_string .= "Done.
";
+
+ if ($delete_mode == 'delete') {
+ $html_string .= "";
+ } else {
+ $html_string .= "";
+ }
+
+ echo "$html_string";
+}
+
+// Delete or shadow a whole sub
+function delete_sub($db, $sub, $settings, $token, $shadow_only)
+{
+
+ $token_hash = hash("sha512",$token);
+ $statement = $db->prepare("SELECT name
+ FROM keys
+ WHERE token = '$token_hash'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $name = "{$row[0]}";
+ }
+
+ $html_string = '';
+
+ if ( ($sub != '') && ($shadow_only == 'no') ) {
+
+ $statement = $db->prepare("DELETE FROM threads
+ WHERE sub = '$sub'");
+ $result = $statement->execute();
+
+ $delete_message = "sub $sub deleted by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ $html_string .= "$delete_message
";
+
+ } elseif ( ($sub != '') && ($shadow_only == 'yes') ) {
+
+ $statement = $db->prepare("UPDATE threads
+ SET shadow = 'yes'
+ WHERE sub = '$sub'");
+ $result = $statement->execute();
+
+ $delete_message = "sub $sub shadowed by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ $html_string .= "$delete_message
";
+ }
+
+ $html_string .= "Done.
";
+
+ if ($shadow_only == 'yes') {
+ $html_string .= "";
+ } else {
+ $html_string .= "";
+ }
+
+ echo "$html_string";
+}
+
+// Delete an admin or mod token to end the session
+function destroy_token($db, $token, $settings)
+{
+ $name = '';
+ $token_hash = hash("sha512", $token);
+ $statement = $db->prepare("SELECT name
+ FROM keys
+ WHERE token = '$token_hash'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $name = "{$row[0]}";
+ }
+
+ if ($name == '') {
+ $logoff_message = "token used to log off was invalid";
+ } else {
+ $logoff_message = "$name logged off";
+ }
+
+ log_event($db, $settings, "auth", $logoff_message, '');
+
+ $statement = $db->prepare("UPDATE keys
+ SET token = 'none'
+ WHERE token = '$token_hash'");
+ $result = $statement->execute();
+
+}
+
+// Dump the contents of a sub,a thread or the whole board to a json
+// file and send it to the browser.
+// If used on the overboard, it will dump everything, including the
+// messages from subs that are not actually displayed on the overboard.
+function dump($db, $sub, $org_id, $settings)
+{
+// rewrite to include ranges
+ header('Content-Type: application/json');
+
+ if ($org_id != '') {
+ $statement = $db->prepare("SELECT post_id, text
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'
+ AND org_id = '$org_id'");
+ $result = $statement->execute();
+ $thread_dump = array();
+ $thread_dump['sub'] = $sub;
+ $thread_dump['org_id'] = $org_id;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post = array();
+ $post['post_id'] = "{$row[0]}";
+ $post['text'] = "{$row[1]}";
+ array_push($thread_dump, $post);
+ }
+
+ echo json_encode($thread_dump, JSON_PRETTY_PRINT
+ | JSON_NUMERIC_CHECK
+ | JSON_UNESCAPED_UNICODE);
+
+ } elseif ($sub == 'overboard') {
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE shadow = 'no'");
+ $result = $statement->execute();
+ $overboard_dump = array();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post = array();
+ $post['post_id'] = "{$row[0]}";
+ $post['org_id'] = "{$row[1]}";
+ $post['sub'] = "{$row[2]}";
+ $post['text'] = "{$row[3]}";
+ array_push($overboard_dump, $post);
+ }
+
+ echo json_encode($overboard_dump, JSON_PRETTY_PRINT
+ | JSON_NUMERIC_CHECK
+ | JSON_UNESCAPED_UNICODE);
+
+ } else {
+ $statement = $db->prepare("SELECT post_id, org_id, text
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'");
+ $result = $statement->execute();
+ $sub_dump = array();
+ $sub_dump['sub'] = $sub;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post = array();
+ $post['post_id'] = "{$row[0]}";
+ $post['org_id'] = "{$row[1]}";
+ $post['text'] = "{$row[2]}";
+ array_push($sub_dump, $post);
+ }
+
+ echo json_encode($sub_dump, JSON_PRETTY_PRINT
+ | JSON_NUMERIC_CHECK
+ | JSON_UNESCAPED_UNICODE);
+
+ }
+}
+
+// Prevents additional tries to log in after too many failed tries.
+// To be configured in the config file.
+// Note: if enabled, this function can be used to dos the access to the
+// admin and mod panels by sending constant junk requests.
+// Still better than someone trying millions of user/password
+// combinations, right ?
+// If really paranoid, enable the admin panel only if you use it or
+// let it listen on another address altogether.
+// Read your logfiles to check if attacks are ongoing.
+function fail2ban($db, $settings)
+{
+
+ if ($settings['enable_fail2ban'] != TRUE) {
+ return;
+ }
+
+ $current = time();
+ $max_age = $current - ($settings['auth_time_frame'] * 60);
+ // max age is in minutes, so times 60 to go to seconds
+
+ if ($settings['superstrict'] == TRUE) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'auth'
+ AND event in (
+ 'wrong combination user/password',
+ 'invalid token used',
+ 'wrong combination name/token',
+ 'fail2ban triggered')
+ AND unix_timestamp > '$max_age'");
+ } else {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'auth'
+ AND event in (
+ 'wrong combination user/password',
+ 'invalid token used',
+ 'wrong combination name/token')
+ AND unix_timestamp > '$max_age'");
+ }
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ($counter > $settings['auth_max']) {
+ $fail2ban_message = "fail2ban triggered";
+ log_event($db, $settings, "auth", $fail2ban_message, '');
+ header("HTTP/1.1 429 Too Many Requests");
+ quit($db, "429");
+ }
+}
+
+// Select a new post_id for a new post. It is one higher than the
+// previous existing highest number.
+// This means in theory that if a message is deleted the number could
+// be assigned to a different one (if it was the latest message that
+// was deleted).
+// This behavior can be prevented when working with a moderators account.
+// Note that in contrast to previous versions, replies are inside
+// the same numbering system.
+function get_new_post_id($db, $sub)
+{
+
+ $largest = 0;
+
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE sub = '$sub'
+ ORDER BY post_id DESC
+ LIMIT 1");
+ // we just want the highest element
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $largest = "{$row[0]}";
+ }
+
+ $largest++;
+
+ return $largest;
+}
+
+// Get the admin or mod token from the post stream.
+function get_post_token()
+{
+
+ $token = '';
+
+ if ( (!empty($_POST['token'])) ) {
+ $token = substr(preg_replace("/[^0-9a-zA-Z]/", "",
+ $_POST['token']), 0, 250);
+ // length of token is 250 characters
+ }
+
+ return $token;
+}
+
+// Check how many pretty vars have been sent
+function get_pretty_vars_count()
+{
+
+ $raw_vars = explode('/', $_SERVER['REQUEST_URI']);
+ $count = count($raw_vars);
+
+ return $count;
+
+}
+
+// Return number of posts in sub or overboard, with or without replies.
+function give_total_posts($db, $sub, $original_only, $settings)
+{
+
+ if ( ($original_only) && ($sub != 'overboard') ) {
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE sub = '$sub'
+ AND post_id = org_id
+ AND shadow = 'no'");
+ $result = $statement->execute();
+ $counter = 0;
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+ } elseif ( ($sub == 'overboard') ) {
+
+ $no_overboard = '';
+ $last = array_pop($settings['no_overboard']);
+
+ foreach($settings['no_overboard'] as $ex_sub) {
+ $str = "'" . $ex_sub . "', ";
+ $no_overboard .= $str;
+ }
+
+ $no_overboard .= "'" . $last . "'";
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE post_id = org_id
+ AND sub NOT IN ($no_overboard)
+ AND shadow = 'no'");
+ $result = $statement->execute();
+ $counter = 0;
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+ } else {
+ $statement = $db->prepare("SELECT post_id
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'");
+ $result = $statement->execute();
+ $counter = 0;
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+ }
+
+ return $counter;
+
+}
+
+// This function can be used to replicate an existing board.
+// Save the dump from the overboard of the existing board, then put
+// it under /var/opt/endboard/board.json of your new server
+// (or whatever name you defined in the config-file).
+// Log in to your admin panel, click on link "import" in the footer
+function import_overboard($db, $settings)
+{
+
+ $board_file = $settings['work_dir'] . $settings['import_file'];
+
+ if ( file_exists($board_file) ) {
+ $html_string = "Found importfile $board_file
";
+ } else {
+ $html_string = "Could not find importfile ";
+ $html_string .= "$board_file, aborting.
";
+ echo "$html_string";
+ return;
+ }
+
+ $board_json = file_get_contents($board_file);
+ $board_list = json_decode($board_json, TRUE);
+ $counter = 0;
+
+ foreach($board_list as $post) {
+ $post_id = $post['post_id'];
+ $text = $post['text'];
+ $sub = $post['sub'];
+ $org_id = $post['org_id'];
+
+ if ( (!empty($text)) && (!empty($sub))
+ && (!empty($post_id)) && (!empty($org_id)) ) {
+ if (!check_post_exists($db, $sub, $org_id, $post_id)) {
+ $global_id = hash("sha512", $sub . $post_id . $org_id . $text);
+ $text_id = hash("sha512", $text);
+
+ $statement = $db->prepare("INSERT OR IGNORE INTO threads
+ (post_id, sub, text, org_id,
+ shadow, global_id, text_id)
+ VALUES ('$post_id', '$sub', ?,
+ '$org_id', 'no', '$global_id',
+ '$text_id')");
+ $statement->bindParam(1, $text);
+
+ $statement->execute();
+
+ $counter++;
+ $import_message = "message $post_id from sub $sub imported";
+ log_event($db, $settings, "import", $import_message, '');
+ $html_string .= "$import_message
";
+ } else {
+ $import_message = "message $post_id from sub $sub";
+ $import_message .= " existed already and was not imported";
+ log_event($db, $settings, "import", $import_message, '');
+ $html_string .= "$import_message
";
+ }
+ }
+ }
+
+ rename($board_file, $board_file . '.done');
+
+ $html_string .= "Finished, imported ";
+ $html_string .= "$counter messages altogether.
";
+ $html_string .= "The boardfile was ";
+ $html_string .= "renamed to avoid another import.
";
+
+ echo "$html_string";
+}
+
+// Log an event to the db. Also, delete the overflow of logs as
+// defined in the config file.
+function log_event($db, $settings, $type, $text, $ip)
+{
+
+ if ( ($settings['enable_logging'] != TRUE) ) {
+ return;
+ }
+
+ $current = time();
+ $timestamp = date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']);
+
+ $statement = $db->prepare("INSERT INTO logs(event, type, timestamp,
+ unix_timestamp, ip)
+ VALUES (?, '$type', '$timestamp',
+ '$current', ?)");
+ $statement->bindParam(1, $text);
+ $statement->bindParam(2, $ip);
+ $statement->execute();
+
+ if ( ($settings['cap_logs'] > 0) ) {
+ $statement = $db->prepare("DELETE FROM logs
+ WHERE ROWID IN
+ (SELECT ROWID FROM logs
+ ORDER BY ROWID DESC
+ LIMIT -1 OFFSET ?)");
+ // to prevent the db from bloating (and to prevent attacks), we
+ // allow only so many lines of logs at any one time, and we check
+ // this with each call.
+ $statement->bindParam(1, $settings['cap_logs']);
+ $result = $statement->execute();
+}
+
+}
+
+// Do some magic number trickery (easteregg)
+function make_id_text($post_id)
+{
+
+ if ($post_id == 1) {
+ $id_text = "first post - yeah";
+ } elseif ($post_id == 42) {
+ $id_text = "...and thanks for all the fish...";
+ } elseif ($post_id == 69) {
+ $id_text = "$post_id π";
+ } elseif ($post_id == 104) {
+ $id_text = "10-4 affirmative";
+ } elseif ($post_id == 143) {
+ $id_text = "$post_id π";
+ } elseif ($post_id == 404) {
+ $id_text = "content not found";
+ } elseif ($post_id == 420) {
+ $id_text = "πΏπΏπΏ";
+ } elseif ($post_id == 666) {
+ $id_text = "πΏπΏπΏ ";
+ } elseif ($post_id == 911) {
+ $id_text = "How can I help you ?";
+ } elseif ($post_id == 1312) {
+ $id_text = "all cats are beautiful";
+ } else {
+ $id_text = $post_id;
+ }
+
+ return $id_text;
+
+}
+
+// Make all tables that are needed (one each for posts, keys, hashes
+// (captchas), hashes (passwords) and logs).
+// Also, the hashes for the captchas are cropped to 20000.
+function make_basic_tables($db)
+{
+
+ // make basic tables: threads, captchas, logs, keys
+ $db->exec('CREATE TABLE IF NOT EXISTS "captchas" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ "hash" TEXT UNIQUE,
+ "unix_timestamp" INTEGER
+ )');
+
+ $db->exec('CREATE TABLE IF NOT EXISTS "logs" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ "timestamp" TEXT,
+ "unix_timestamp" INTEGER,
+ "type" TEXT,
+ "event" TEXT,
+ "ip" TEXT
+ )');
+
+ $db->exec('CREATE TABLE IF NOT EXISTS "keys" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ "name" TEXT UNIQUE NOT NULL,
+ "type" INTEGER NOT NULL,
+ "email" TEXT,
+ "key" TEXT,
+ "subs" TEXT,
+ "token" TEXT,
+ "timestamp_token" INTEGER
+ )');
+
+ $db->exec('CREATE TABLE IF NOT EXISTS "threads" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ "post_id" INTEGER NOT NULL,
+ "shadow" TEXT NOT NULL,
+ "sub" TEXT NOT NULL,
+ "global_id" TEXT NOT NULL UNIQUE,
+ "text_id" TEXT NOT NULL,
+ "text" TEXT NOT NULL,
+ "org_id" INTEGER NOT NULL,
+ UNIQUE(post_id, sub) ON CONFLICT IGNORE
+ )');
+
+ $statement = $db->prepare("DELETE FROM captchas
+ WHERE ROWID IN
+ (SELECT ROWID FROM captchas
+ ORDER BY ROWID DESC
+ LIMIT -1 OFFSET 20000)");
+ // to prevent the db from bloating (and to prevent attacks), we
+ // allow only 20000 captchas at any one time, and we check this
+ // with each call. This should be enough if your site is getting
+ // less or equal to 100.000 visitors a day.
+ // Total combinations of captcha and token are ca. 2000 * 62^250.
+ $result = $statement->execute();
+
+}
+
+// Make a new post to a sub
+function make_post($db, $sub, $settings, $text, $org_id)
+{
+
+ $post_id = get_new_post_id($db, $sub);
+
+ if ($org_id == '') {
+ $org_id = $post_id;
+ } elseif ( (!check_org_id_exists($db, $sub, $org_id)) ) {
+ quit($db, "Post $org_id on sub $sub does not exist.
");
+ }
+
+ $global_id = hash("sha512", $sub . $post_id . $org_id . $text);
+ $text_id = hash("sha512", $text);
+
+ $statement = $db->prepare("INSERT INTO threads(post_id, sub, text,
+ org_id, shadow,
+ global_id, text_id)
+ VALUES ('$post_id', '$sub', ?, '$org_id',
+ 'no', '$global_id', '$text_id')");
+ $statement->bindParam(1, $text);
+ $statement->execute();
+
+ if ( ($org_id != $post_id) && ($settings['enable_bumping'] == TRUE) ){
+ bump_message($db, $org_id, $sub);
+ }
+
+ return $post_id;
+}
+
+// Make a token to grant access to the admin panel or the mod panel for
+// a limited time. A way of having sessions without cookies.
+// Also used as a hidden field in the post form to prevent double
+// posting by sending the same input twice.
+function make_token()
+{
+
+ $characters = '0123456789';
+ $characters .= 'abcdefghijklmnopqrstuvwxyz';
+ $characters .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ $random_string = '';
+
+ for ($i = 0; $i < 250; $i++) {
+ // token length is set to 250 characters
+ $index = random_int(0, 61);
+ // we have 62 to choose, so 0 to 61
+ $random_string .= $characters[$index];
+ }
+
+ return $random_string;
+}
+
+// Moves a post to a different sub. In case it is an original post, all
+// replies re moved as well.
+function move_post($db, $settings, $delete_array, $sub, $target_sub, $name)
+{
+ foreach($delete_array as $delete_post) {
+ $statement = $db->prepare("UPDATE threads
+ SET shadow = 'yes'
+ WHERE post_id = '$delete_post'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ $statement = $db->prepare("SELECT post_id, org_id, text
+ FROM threads
+ WHERE post_id = '$delete_post'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $text = "{$row[2]}";
+ }
+
+ if ( ($post_id == $org_id) ) {
+ $text .= "\r\n\r\n\r\n\r\n [system generated message: \r\n";
+ $text .= "post $post_id from sub \"$sub\" was moved \r\n";
+ $text .= "to sub \"$target_sub\"]";
+ $new_id = make_post($db, $target_sub, $settings, $text, '');
+ $delete_message = "message $delete_post from sub $sub";
+ $delete_message .= " moved to $target_sub by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ echo "$delete_message
";
+
+ $statement = $db->prepare("UPDATE threads
+ SET shadow = 'yes'
+ WHERE org_id = '$post_id'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ $statement = $db->prepare("SELECT post_id, org_id, text
+ FROM threads
+ WHERE org_id = '$post_id'
+ AND post_id != '$post_id'
+ AND sub = '$sub'");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $text = "{$row[2]}";
+
+ $text .= "\r\n\r\n\r\n\r\n [system generated message:\r\n";
+ $text .= "post $post_id from sub \"$sub\" was moved \r\n";
+ $text .= "to sub \"$target_sub\"]";
+ make_post($db, $target_sub, $settings, $text, $new_id);
+
+ $delete_message = "message $post_id from sub $sub moved ";
+ $delete_message .= "to $target_sub by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ echo "$delete_message
";
+ }
+ } else {
+ $text .= "\r\n\r\n\r\n\r\n [system generated message: post\r\n";
+ $text .= "$post_id from sub \"$sub\" was moved \r\n";
+ $text .= "to sub \"$target_sub\"]";
+ make_post($db, $target_sub, $settings, $text, '');
+ $delete_message = "message $post_id from sub $sub moved";
+ $delete_message .= " to $target_sub by $name";
+ log_event($db, $settings, "del", $delete_message, '');
+
+ echo "$delete_message
";
+ }
+
+ }
+
+}
+
+// checks if posts from bots can be received or not
+function post_block_bot($db, $settings, $visitor_ip)
+{
+
+ $current = time();
+ $max_age = $current - ($settings['max_post_timeframe'] * 60);
+ // the number from settings is in minutes, so times 60 for secs
+
+ if ( ($settings['max_post_global'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type in ('bot', 'user')
+ AND event = 'post attempt'
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter > $settings['max_post_global']) ) {
+ return FALSE;
+ }
+ }
+
+ if ( ($settings['max_post_ip'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type in ('bot', 'user')
+ AND event = 'post attempt'
+ AND ip = '$visitor_ip'
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter > $settings['max_post_ip']) ) {
+ return FALSE;
+ }
+ }
+
+ if ( ($settings['max_post_bot'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type = 'bot'
+ AND event = 'post attempt'
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter > $settings['max_post_bot']) ) {
+ return FALSE;
+ }
+
+ }
+
+ return TRUE;
+}
+// checks if posts from users can be received or not
+function post_block_user($db, $settings, $visitor_ip)
+{
+
+ $current = time();
+ $max_age = $current - ($settings['max_post_timeframe'] * 60);
+ // the number from settings is in minutes, so times 60 for secs
+
+ if ( ($settings['max_post_global'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type in ('bot', 'user')
+ AND event = 'post attempt'
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter > $settings['max_post_global']) ) {
+ return FALSE;
+ }
+ }
+
+ if ( ($settings['max_post_ip'] > 0) ) {
+ $statement = $db->prepare("SELECT unix_timestamp
+ FROM logs
+ WHERE type in ('bot', 'user')
+ AND event = 'post attempt'
+ AND ip = '$visitor_ip'
+ AND unix_timestamp > '$max_age'");
+
+ $result = $statement->execute();
+
+ $counter = 0;
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $counter++;
+ }
+
+ if ( ($counter > $settings['max_post_ip']) ) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+// Show each post in a thread
+function print_thread($db, $sub, $css, $settings, $org_id)
+{
+
+ $html_string = "";
+
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads WHERE sub = '$sub'
+ AND org_id = '$org_id'
+ AND shadow = 'no'
+ ORDER BY post_id DESC");
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $post_text = "{$row[3]}";
+ $post_text = break_text(bbcode_to_html($post_text, $settings),
+ $settings);
+ $id_text = make_id_text($post_id);
+
+ $html_string .= "
";
+ $html_string .= "
#$id_text
";
+ $html_string .= "$post_text";
+ $html_string .= "
";
+ }
+
+ $html_string .= "
";
+
+ echo "$html_string";
+
+}
+
+// Show each post of the overboard (so all original posts in their
+// sequence, including bumps, except for the subs that are excluded)
+function print_overboard($db, $css, $settings, $page)
+{
+
+ $out = '';
+ $pagination = $settings['pagination'];
+
+ if ( (!empty($settings['no_overboard'])) ) {
+ $last = array_pop($settings['no_overboard']);
+
+ foreach($settings['no_overboard'] as $no_overboard) {
+ $str = "'" . $no_overboard . "', ";
+ $out .= $str;
+ }
+
+ $out .= "'" . $last . "'";
+
+ }
+
+ if ($page == 'all') {
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE post_id = org_id
+ AND shadow = 'no'
+ AND sub NOT IN ($out)
+ ORDER BY ROWID DESC");
+ } elseif ($page > 0) {
+ // if the page is defined
+ $page_start = ($page - 1) * $settings['pagination'];
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE post_id = org_id
+ AND shadow = 'no'
+ AND sub NOT IN ($out)
+ ORDER BY ROWID DESC
+ LIMIT '$page_start', '$pagination'");
+ } else {
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE post_id = org_id
+ AND shadow = 'no'
+ AND sub NOT IN ($out)
+ ORDER BY ROWID DESC
+ LIMIT '$pagination'");
+ }
+
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+
+ $html_string = '';
+
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $sub = "{$row[2]}";
+ $text = "{$row[3]}";
+ $post_text = break_text(bbcode_to_html($text, $settings), $settings);
+
+ $html_string .= "";
+ $link_string_1 = "/r/$sub/$org_id/op/$css";
+ $link_string_2 = "/r/$sub/$org_id/$css";
+ $link_string_3 = "/s/$sub/$css";
+ $html_string .= "
";
+ $html_string .= "
$sub ";
+ $html_string .= "
";
+ }
+
+}
+
+// Show each post of an individual feed
+function print_individual_feed($db, $css, $settings, $ex_subs, $in_subs)
+{
+
+ $counter = 0;
+
+ if ( (!empty($ex_subs)) ) {
+ $out = '';
+ $last = array_pop($ex_subs);
+
+ foreach($ex_subs as $ex_sub) {
+ $str = "'" . $ex_sub . "', ";
+ $out .= $str;
+ }
+
+ $out .= "'" . $last . "'";
+
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE post_id = org_id
+ AND shadow = 'no'
+ AND sub NOT IN ($out)
+ ORDER BY ROWID DESC");
+ } elseif ( (!empty($in_subs)) ) {
+ $in = '';
+ $last = array_pop($in_subs);
+
+ foreach($in_subs as $in_sub) {
+ $str = "'" . $in_sub . "', ";
+ $in .= $str;
+ }
+
+ $in .= "'" . $last . "'";
+
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE post_id = org_id
+ AND shadow = 'no'
+ AND sub IN ($in)
+ ORDER BY ROWID DESC");
+ }
+
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+
+ $html_string = '';
+
+ $counter++;
+
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $sub = "{$row[2]}";
+ $text = "{$row[3]}";
+
+ $post_text = break_text(bbcode_to_html($text, $settings), $settings);
+
+ $link_string_1 = "/r/$sub/$org_id/op/$css";
+ $link_string_2 = "/r/$sub/$org_id/$css";
+ $link_string_3 = "/s/$sub/$css";
+
+ $html_string .= "";
+ $html_string .= "
$sub ";
+ $html_string .= "
";
+ $html_string .= "#$post_id$post_text
";
+ $html_string .= "
reply ";
+
+ echo "$html_string";
+
+ print_replies($db, $sub, $post_id, $org_id, $settings, $css);
+
+ echo "
";
+ }
+
+ return $counter;
+}
+
+// Show each post in a sub
+function print_sub($db, $sub, $css, $settings, $page)
+{
+
+ echo "";
+
+ $pagination = $settings['pagination'];
+
+ if ($page == 'all') {
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'
+ AND post_id = org_id
+ ORDER BY ROWID DESC");
+ } elseif ($page > 0) {
+ // if the page is defined
+ $page_start = ($page - 1) * $settings['pagination'];
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'
+ AND post_id = org_id
+ ORDER BY ROWID DESC
+ LIMIT '$page_start', '$pagination'");
+ } else {
+ $statement = $db->prepare("SELECT post_id, org_id, sub, text
+ FROM threads
+ WHERE sub = '$sub'
+ AND shadow = 'no'
+ AND post_id = org_id
+ ORDER BY ROWID DESC
+ LIMIT '$pagination'");
+ }
+
+ $result = $statement->execute();
+
+ while ($row = $result->fetchArray(SQLITE3_NUM)) {
+
+ $html_string = '';
+
+ $post_id = "{$row[0]}";
+ $org_id = "{$row[1]}";
+ $text = "{$row[3]}";
+ $post_text = break_text(bbcode_to_html($text, $settings), $settings);
+
+ $html_string .= "
";
+
+ $id_text = make_id_text($post_id);
+ $link_string_1 = "/r/$sub/$org_id/op/$css";
+ $link_string_2 = "/r/$sub/$org_id/$css";
+
+ $html_string .= "
";
+ $html_string .= "
#$id_text";
+ $html_string .= "
$post_text
";
+ $html_string .= "
reply";
+
+ echo "$html_string";
+
+ print_replies($db, $sub, $post_id, $org_id, $settings, $css);
+
+ echo "
";
+ echo "
";
+ }
+
+ echo "
";
+
+}
+
+// Print footer, with the total messages and some links, for the subs
+function print_footer_sub($sub, $total_posts, $css, $page, $settings)
+{
+
+ if ( ($total_posts > $settings['pagination']) && ($page != 'all') ) {
+ $number_first_message = ($page - 1) * $settings['pagination'] + 1;
+ $number_last_message =
+ $number_first_message + $settings['pagination'] - 1;
+
+ if ($number_last_message > $total_posts) {
+ $number_last_message = $total_posts;
+ }
+
+ $next_page = $page + 1;
+ $prev_page = $page - 1;
+ $pages_total = ceil($total_posts / $settings['pagination']);
+
+ $show_string_1 = "page:$page/$pages_total|posts:";
+ $show_string_1 .= "$number_first_message";
+ $show_string_1 .= "-$number_last_message/$total_posts|";
+
+ $link_string_1 = "/s/$sub/$next_page/$css";
+ $link_string_2 = "/s/$sub/$prev_page/$css";
+ $link_string_3 = "/s/$sub/all/$css";
+
+ if ( ($number_first_message > 1)
+ && ($number_last_message < $total_posts) ) {
+ $left_section = "$show_string_1overboard|";
+ $mid_section .= "
show subs";
+ $right_section = "/d/$sub'>save main
";
+ } elseif ( ($sub == 'overboard') ) {
+ $mid_section = "/s/main/1/$css'>main|";
+ $mid_section .= "show subs";
+ $right_section = "/d/overboard'>save overboard";
+ } else {
+ $mid_section = "/s/main/1/$css'>main|";
+ $mid_section .= "overboard|";
+ $mid_section .= "show subs";
+ $right_section = "/d/$sub'>save $sub";
+ }
+
+ $html_string = "
";
+ } elseif ($settings['take_applications'] == TRUE) {
+ $right_section = "apply for mod account";
+ } else {
+ $right_section = "**********";
+ }
+
+ $html_string .= "$mid_section";
+ $html_string .= "
$right_section";
+ $html_string .= "