dev_endboard/opt/post.php

644 lines
21 KiB
PHP

<?php
/*
* This is the endboard software, version beta 0.80
* It is a textboard written for the use in the darknets.
*
* This file holds all the functions used to make posts. It can be
* included without side effects.
*
* The writing of this code started some time ago with another software
* called smolBBS. Although there is almost no original code left now,
* I still regard endboard as a fork of smolBBS.
* The author of smolBBS has required that the following text be
* distributed with any redistribution, so here it goes.
* The license and other conditions apply to endboard as well.
*
* IRC: *dulm @ irc.rizon.net
*
* Copyright (C) 2020 sandlind
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* (1) Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* (2) Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
*
* (3)The name of the author may not be used to
* endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
* IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
// Give a new location to the browser.
// Does not work with lynx, unfortunately, but then the link can be used.
function answer_redirect($sub, $css, $post_id, $settings)
{
$random_string = make_token(10, 'alpha');
if ( ( $settings['enable_tripcodes'] == TRUE ) &&
( !empty($_POST['combination']) ) &&
( !empty($_POST['combination_hidden']) ) &&
( $_POST['combination'] == $_POST['combination_hidden'] ) ) {
header( "refresh:10;url=/s/$sub/css=$css/random=$random_string" );
// we wait 10 seconds with the redirection
$credentials = $_POST['combination'];
$html_string = '<title>wait for it...</title></head>'
. '<h1>Redirection in about 10 secs.'
. ' Save this string, if you '
. ' want to edit your post later on: <br>'
. "$credentials<br>"
. ' If the redirection does not work, click '
. "<a href='/s/$sub/css=$css/"
. "random=$random_string'>here</a>"
. ' to go back.';
} else {
header( "refresh:3;url=/s/$sub/css=$css/random=$random_string" );
// we wait 3 seconds with the redirection
$html_string = '<title>wait for it...</title></head>'
. '<h1>Redirection in about 3 secs.'
. ' If that does not work, go '
. "<a href='/s/$sub/css=$css/"
. "random=$random_string'>back</a>.";
}
echo "$html_string";
}
// If the post is a reply, put the original post on top.
function bump_message($db, $org_id, $sub, $settings)
{
if ( $settings['auto_sage'] > 0 ) {
$statement = $db->prepare("SELECT global_id
FROM threads
WHERE org_id = '$org_id'
AND sub = '$sub'");
$result = $statement->execute();
$counter = 0;
while ($row = $result->fetchArray(SQLITE3_NUM)) {
$counter++;
}
if ( $counter > $settings['auto_sage'] ) {
return;
}
}
$statement = $db->prepare("SELECT post_id, text, global_id, text_id,
timestamp, name, tripcode, original,
edit_message, move_message
FROM threads
WHERE original = '$org_id'
AND shadow = 'no'
AND sub = '$sub'");
$result = $statement->execute();
while ($row = $result->fetchArray(SQLITE3_NUM)) {
$post_id = "{$row[0]}";
$text = "{$row[1]}";
$global_id = "{$row[2]}";
$text_id = "{$row[3]}";
$timestamp = "{$row[4]}";
$name = "{$row[5]}";
$tripcode = "{$row[6]}";
$original = "{$row[7]}";
$edit_message = "{$row[8]}";
$move_message = "{$row[9]}";
}
$statement = $db->prepare("DELETE FROM threads
WHERE original = '$org_id'
AND shadow = 'no'
AND sub = '$sub'");
$result = $statement->execute();
$statement = $db->prepare("INSERT INTO threads(post_id, sub, text,
org_id, shadow,
global_id, text_id,
timestamp, name, tripcode,
original,
edit_message,
move_message)
VALUES ('$post_id', '$sub', ?, '$org_id',
'no', '$global_id', '$text_id',
'$timestamp', '$name', '$tripcode',
'$original', ?, ?)");
$statement->bindParam(1, $text);
$statement->bindParam(2, $edit_message);
$statement->bindParam(3, $move_message);
$statement->execute();
}
// Check the hashed captcha against the hashed solutions in the db.
function check_captcha($db, $settings)
{
if ( (!isset($_POST['post_token'])) ) {
quit($db, '<h1>What are you up to ? Use the postform.</h1>');
}
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, '<h1>What are you up to ? Use the postform.</h1>');
} 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 = '<h1>Unauthorized attempt to post.'
. 'Use a newly opened postform.</h1>';
quit($db, $quit_message);
} elseif ( ($counter < 1) ) {
quit($db, '<h1>wrong answer or captcha expired, try again</h1>');
} 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($settings['work_dir']);
if ($free_space < ($settings['min_space'] * 1024 * 1024)) {
// the setting is in Megabyte, free space operates in bytes
return FALSE;
}
return TRUE;
}
// Check if a name exists already, and if yes, check credentials.
// If not, return false.
function check_name($db, $name, $tripkey)
{
$statement = $db->prepare("SELECT tripcode
FROM threads
WHERE name = '$name'");
$result = $statement->execute();
$tripcode = '';
while ($row = $result->fetchArray(SQLITE3_NUM)) {
$tripcode = "{$row[0]}";
if (!password_verify($name . $tripkey, $tripcode)) {
// add logging to prevent password spraying attacks
quit($db, "<h1>Name#tripkey combination is not valid.</h1>");
}
break;
}
if (empty($tripcode)) {
$tripcode = password_hash($name . $tripkey, PASSWORD_DEFAULT);
}
return $tripcode;
}
// 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 org_id = '$org_id'");
$result = $statement->execute();
$counter = 0;
while ($row = $result->fetchArray(SQLITE3_NUM)) {
$counter++;
break;
}
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, '<h1>Spam detected!</h1>');
}
$post_length = strlen($text);
if ($post_length < $settings['min_char']) {
quit($db, '<h1>Post too short!</h1>');
}
if ($post_length > $settings['max_char']) {
quit($db, '<h1>Post too long!</h1>');
}
$text = str_replace(array("\n","\r"), '', $text);
if (substr_count($text, ' ') === strlen($text)) {
quit($db, '<h1>Spam detected! Post contained only spaces!</h1>');
}
//rewrite, does not work for all cases
if (ctype_space($text)) {
$quit_message = '<h1>Spam detected! Post contained only spaces '
. '(yeah, Unicode...)!</h1>';
quit($db, $quit_message);
}
}
// 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++;
// we increase the largest number by one to get the new post_id
return $largest;
}
// Get the token from the post stream.
function get_post_token()
{
$token = '';
if ( (!empty($_POST['token'])) ) {
$token = filter($_POST['token'], 'alnum', 250);
// length of token is 250 characters
}
return $token;
}
// Make an edit to an existing post
function make_edit($db, $sub, $post_id, $ip, $settings)
{
if ( ( $settings['enable_tripcodes'] != TRUE ) ) {
quit($db, "<h1>Tripcodes need to be enabled for this to work.</h1>");
}
if ( (!check_post_exists($db, $sub, $post_id)) ) {
quit($db, "<h1>Post $post_id on sub $sub does not exist.</h1>");
}
if ( ( $settings['enable_timestamps'] == TRUE ) &&
( (!empty($_POST['edit_timestamp'])) ) ) {
$timestamp = make_timestamp($settings);
} else {
$timestamp = '';
}
if ( (!empty($_POST['edit_combination'])) ) {
$parts = explode('#', $_POST['edit_combination']);
if ( (empty($parts[0])) || (empty($parts[1])) ) {
quit($db, "<h1>Name#tripkey not found.</h1>");
}
$name = filter($parts[0], 'email', 20);
$tripkey = filter($parts[1], 'alnum', 50);
} else {
quit($db, "<h1>Name#tripkey are needed for this.</h1>");
}
$statement = $db->prepare("SELECT tripcode, org_id, original,
move_message
FROM threads
WHERE sub = '$sub'
AND shadow = 'no'
AND post_id = '$post_id'
ORDER BY ROWID DESC");
$result = $statement->execute();
while ($row = $result->fetchArray(SQLITE3_NUM)) {
$tripcode_post = "{$row[0]}";
$org_id = "{$row[1]}";
$original = "{$row[2]}";
$move_message = "{$row[3]}";
}
if (!password_verify($name . $tripkey, $tripcode_post)) {
$auth_message = 'name#tripkey combination not valid';
log_event($db, $settings, 'auth', $auth_message, $ip);
sleep(10);
quit($db, "<h1>Name#tripkey combination is not valid.</h1>");
}
$statement = $db->prepare("UPDATE threads
SET shadow = 'yes'
WHERE post_id = '$post_id'
AND sub = '$sub'");
$result = $statement->execute();
$new_post_id = get_new_post_id($db, $sub);
$text = strip_tags($_POST['edit_text']);
$text_id = hash('sha512', $text);
$global_id = hash('sha512', $sub . $new_post_id . $org_id . $text);
$edit_message = 'edited by user, click "edit" to see the history';
$statement = $db->prepare("INSERT INTO threads(post_id, sub, text,
org_id, shadow,
global_id, text_id,
timestamp, name,
tripcode, original,
move_message,
edit_message)
VALUES ('$new_post_id', '$sub', ?, '$org_id',
'no', '$global_id', '$text_id',
'$timestamp', '$name',
'$tripcode_post',
'$original', ?, ?)");
$statement->bindParam(1, $text);
$statement->bindParam(2, $move_message);
$statement->bindParam(3, $edit_message);
$statement->execute();
return $post_id;
}
// Make a timestamp, precision is set in the config file
function make_timestamp($settings){
$month = gmdate("F");
$year = gmdate("Y");
if ( $settings['precision_timestamps'] == 'middle' ) {
$timestamp = $month . '/' . $year;
} elseif ( $settings['precision_timestamps'] == 'high' ) {
$timestamp = gmdate("Y-m-d");
} elseif ( $settings['precision_timestamps'] == 'insane' ) {
$timestamp = gmdate("Y-m-d H:i:s");
} else {
$quarter_1 = array('January', 'February', 'March');
$quarter_2 = array('April', 'May', 'June');
$quarter_3 = array('July', 'August', 'September');
$quarter_4 = array('October', 'November', 'December');
if ( in_array($month, $quarter_1 ) ){
$timestamp = 'Q1';
} elseif ( in_array($month, $quarter_2 ) ){
$timestamp = 'Q2';
} elseif ( in_array($month, $quarter_3 ) ){
$timestamp = 'Q3';
} elseif ( in_array($month, $quarter_4 ) ){
$timestamp = 'Q4';
}
$timestamp .= '/' . $year;
}
return $timestamp;
}
// 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, "<h1>Post $org_id on sub $sub does not exist.</h1>");
}
$global_id = hash('sha512', $sub . $post_id . $org_id . $text);
$text_id = hash('sha512', $text);
if ( ( $settings['enable_timestamps'] == TRUE ) &&
( (!empty($_POST['timestamp'])) ) ) {
$timestamp = make_timestamp($settings);
} else {
$timestamp = '';
}
if ( ( $settings['enable_tripcodes'] == TRUE ) &&
(!empty($_POST['combination'])) ) {
$parts = explode('#', $_POST['combination']);
$name = filter($parts[0], 'email', 20);
$tripkey = filter($parts[1], 'alnum', 50);
$tripcode = check_name($db, $name, $tripkey);
} elseif ( ( $settings['enable_tripcodes'] == TRUE ) &&
(!empty($_POST['mod'])) &&
(!empty($_POST['pass'])) ) {
$name = filter($_POST['mod'], 'email', 20);
$tripkey = filter($_POST['pass'], 'alnum', 50);
$tripcode = check_name($db, $name, $tripkey);
} else {
$name = '';
$tripcode = '';
}
$statement = $db->prepare("INSERT INTO threads(post_id, sub, text,
org_id, shadow,
global_id, text_id,
timestamp, name,
tripcode, original)
VALUES ('$post_id', '$sub', ?, '$org_id',
'no', '$global_id', '$text_id',
'$timestamp', '$name', '$tripcode',
'$post_id')");
$statement->bindParam(1, $text);
$statement->execute();
if ( ($org_id != $post_id) &&
($settings['enable_bumping'] == TRUE) &&
(!isset($_POST['sage'])) ){
bump_message($db, $org_id, $sub, $settings);
}
return $post_id;
}
function make_tripcode($settings)
{
$tripkey = make_token(25, 'alnum');
$differ = make_token(6, 'alnum');
$name = $settings['prefix_autogen'] . $differ;
$combination = $name . '#' . $tripkey;
return $combination;
}
// 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;
}
// register a new sub in the table "subs"
function register_sub($db, $name, $sub, $motto, $css, $botkey)
{
$statement = $db->prepare("INSERT INTO subs(name, type, moderator,
botkey, css, motto)
VALUES ('$sub', 'public', '$name',
'$botkey', '$css', ?)");
$statement->bindParam(1, $motto);
$statement->execute();
}
// EOF