4chan/team/stats.php
2025-04-17 18:12:08 -05:00

1046 lines
23 KiB
PHP

<?php
require_once 'lib/sec.php';
require_once 'lib/admin.php';
require_once 'lib/auth.php';
require_once 'lib/csp.php';
define('IN_APP', true);
auth_user();
if (!has_level('mod')) {
APP::denied();
}
require_once 'lib/csp.php';
/*
if (has_flag('developer')) {
//$mysql_suppress_err = false;
//ini_set('display_errors', 1);
//error_reporting(E_ALL);
}
else {
die('503');
}
*/
require_once 'lib/ini.php';
class App {
protected
// Routes
$actions = array(
'index',
'recent_images',
'spamtest'
),
$stats_mode = false,
$option_threads = null
;
const TPL_ROOT = 'views/';
const
TIME_RANGE = 72, // hours
TIME_RANGE_2 = 168, // hours
TIME_RANGE_3 = 24, // hours
USER_ACTIONS_TABLE = 'user_actions',
ACTIONS_LOG_TABLE = 'actions_log',
BOARD_STATS_TABLE = 'board_stats',
BANS_TABLE = 'banned_users',
DEL_LOG_TABLE = 'del_log',
ARCHIVE_ACTION_ID = 131, // 128 + 3, defined in /stafflog.php
RES_HARD_LIMIT = 2500,
WEBROOT = '/stats',
MAX_MONTHS = 13
;
final protected function success($redirect = null, $no_exit = false) {
$this->redirect = $redirect;
$this->renderHTML('success');
if (!$no_exit) {
die();
}
}
final protected function error($msg) {
$this->message = $msg;
$this->renderHTML('error');
die();
}
static public function denied() {
require_once(self::TPL_ROOT . 'denied.tpl.php');
die();
}
/**
* Renders HTML template
*/
private function renderHTML($view) {
include(self::TPL_ROOT . $view . '.tpl.php');
}
/**
* Returns the data as json
*/
final protected function successJSON($data = null) {
$this->renderJSON(array('status' => 'success', 'data' => $data));
die();
}
/**
* Returns the error as json and exits
*/
final protected function errorJSON($message = null) {
$payload = array('status' => 'error', 'message' => $message);
$this->renderJSON($payload);
die();
}
/**
* Returns a JSON response
*/
private function renderJSON($data) {
header('Content-type: application/json');
echo json_encode($data);
}
private function pretty_ttl($delta) {
if ($delta < 60) {
return '< 1 minute';
}
if ($delta < 3600) {
$count = floor($delta / 60);
if ($count > 1) {
return $count . ' minutes';
}
else {
return '1 minute';
}
}
if ($delta < 86400) {
$count = floor($delta / 3600);
if ($count > 1) {
$head = $count . ' hours';
}
else {
$head = '1 hour';
}
$tail = floor($delta / 60 - $count * 60);
if ($tail > 1) {
$head .= ' and ' . $tail . ' minutes';
}
return $head;
}
$count = floor($delta / 86400);
if ($count > 1) {
$head = $count . ' days';
}
else {
$head = '1 day';
}
$tail = floor($delta / 3600 - $count * 24);
if ($tail > 1) {
$head .= ' and ' . $tail . ' hours';
}
return $head;
}
private function get_board_titles($board = null) {
if ($board !== null) {
$query = "SELECT name FROM boardlist WHERE dir = '%s'";
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
if (mysql_num_rows($res) < 1) {
$this->error('Invalid board.');
}
return mysql_fetch_row($res)[0];
}
$query = "SELECT dir, name FROM boardlist ORDER BY dir";
$res = mysql_global_call($query);
if (!$res) {
$this->error('Database Error.');
}
$boards = array();
while ($row = mysql_fetch_row($res)) {
$boards[$row[0]] = $row[1];
}
return $boards;
}
private function get_mobile_ua_stats($board) {
$sql = <<<SQL
SELECT SUBSTR(email, 1, 1) as ua, COUNT(*) as cnt FROM `%s`
WHERE archived = 0 AND email != '' GROUP BY SUBSTR(email, 1, 1)
SQL;
$res = mysql_board_call($sql, $board);
if (!$res) {
return null;
}
$data = [];
while ($row = mysql_fetch_assoc($res)) {
$data[$row['ua']] = (int)$row['cnt'];
}
return $data;
}
/**
* Number of new threads and posts per hour.
* Doesn't take into account deleted posts.
* returns array('threads' => array(threads_per_hour),
* 'posts' => array(posts_per_hour))
*/
private function get_posting_rate($board) {
$actions_tbl = self::USER_ACTIONS_TABLE;
$now = $_SERVER['REQUEST_TIME'];
$now_hour = (int)date('G', $now);
$thres = $now - 26 * 3600;
$now_timegrp = floor($now / 3600);
// New threads
$query = <<<SQL
SELECT COUNT(*) AS val, FLOOR(UNIX_TIMESTAMP(time) / 3600) AS timegrp
FROM `$actions_tbl`
WHERE action = 'new_thread' AND board = '%s' AND time >= DATE_SUB(NOW(), INTERVAL 26 HOUR)
GROUP BY timegrp
ORDER BY timegrp
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$data = array();
while ($row = mysql_fetch_assoc($res)) {
$data[(int)$row['timegrp']] = (int)$row['val'];
}
$total_threads = 0;
$new_threads = array();
for ($delta = 1; $delta < 25; ++$delta) {
$timegrp = $now_timegrp - $delta;
$hour = $now_hour - $delta;
if ($hour < 0) {
$hour += 24;
}
if (isset($data[$timegrp])) {
$new_threads[$hour] = $data[$timegrp];
$total_threads += $data[$timegrp];
}
else {
$new_threads[$hour] = 0;
}
}
ksort($new_threads);
$new_threads = array_values($new_threads);
// New posts
$query = <<<SQL
SELECT COUNT(*) AS val, FLOOR(UNIX_TIMESTAMP(time) / 3600) AS timegrp
FROM `$actions_tbl`
WHERE action = 'new_reply' AND board = '%s' AND time >= DATE_SUB(NOW(), INTERVAL 26 HOUR)
GROUP BY timegrp
ORDER BY timegrp
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$data = array();
while ($row = mysql_fetch_assoc($res)) {
$data[(int)$row['timegrp']] = (int)$row['val'];
}
$total_replies = 0;
$new_replies = array();
for ($delta = 1; $delta < 25; ++$delta) {
$timegrp = $now_timegrp - $delta;
$hour = $now_hour - $delta;
if ($hour < 0) {
$hour += 24;
}
if (isset($data[$timegrp])) {
$new_replies[$hour] = $data[$timegrp];
$total_replies += $data[$timegrp];
}
else {
$new_replies[$hour] = 0;
}
}
ksort($new_replies);
$new_replies = array_values($new_replies);
return array(
'threads' => $new_threads,
'replies' => $new_replies,
'total_threads' => $total_threads,
'total_replies' => $total_replies
);
}
private function get_unique_ips_count($board) {
$actions_tbl = self::USER_ACTIONS_TABLE;
$thres = self::TIME_RANGE_3;
$query = <<<SQL
SELECT COUNT(DISTINCT(ip)) as cnt FROM `$actions_tbl` WHERE board = '%s' AND
time > DATE_SUB(NOW(), INTERVAL $thres HOUR) AND
(action = 'new_reply' OR action = 'new_thread')
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error (guic1)');
}
return (int)mysql_fetch_row($res)[0];
}
private function get_option_threads($board) {
if ($this->option_threads !== null) {
return $this->option_threads;
}
$actions_log_tbl = self::ACTIONS_LOG_TABLE;
$thres_th_ttl = (int)self::TIME_RANGE_2;
$query = <<<SQL
SELECT postno FROM `$actions_log_tbl`
WHERE board = '%s'
AND ts > DATE_SUB(NOW(), INTERVAL $thres_th_ttl HOUR)
ORDER BY id DESC LIMIT 500
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$in_clause = array();
while ($row = mysql_fetch_row($res)) {
$in_clause[] = (int)$row[0];
}
$this->option_threads = $in_clause;
return $in_clause;
}
/*
private function get_unique_posters_count($board) {
$query = "SELECT pwd, host FROM `%s` WHERE archived = 0";
$res = mysql_board_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$ips_per_pwd = [];
while ($row = mysql_fetch_assoc($res)) {
if (!isset($ips_per_pwd[$row['pwd']])) {
$ips_per_pwd[$row['pwd']] = [];
}
$ips_per_pwd[$row['pwd']][] = $row['host'];
}
$dup_ips = [];
$count = 0;
foreach ($ips_per_pwd as $pwd => $ips) {
$skip = false;
foreach ($ips as $ip) {
if (isset($dup_ips[$ip])) {
$skip = true;
}
else {
$dup_ips[$ip] = true;
}
}
if (!$skip) {
$count++;
}
}
return $count;
}
*/
private function get_board_bump_limit($board) {
global $yconfgdir;
if (!preg_match('/^[0-9a-z]{1,6}$/', $board)) {
return 0;
}
$bump_limit = 0;
// using parse_ini fails for some reason
$cfg = file_get_contents("$yconfgdir/boards/$board.config.ini");
if (!$cfg) {
return 0;
}
if (preg_match('/^MAX_RES ?= ?([0-9]+)/m', $cfg, $m)) {
$bump_limit = (int)$m[1];
}
else {
$cfg = file_get_contents("$yconfgdir/global_config.ini");
if (!$cfg) {
return 0;
}
if (preg_match('/^MAX_RES ?= ?([0-9]+)/m', $cfg, $m)) {
$bump_limit = (int)$m[1];
}
}
return $bump_limit;
}
/**
* Compiles stats about thread lifetimes
*/
private function get_thread_ttl_stats($board) {
$actions_log_tbl = self::ACTIONS_LOG_TABLE;
$thres = (int)self::TIME_RANGE;
$bump_limit = $this->get_board_bump_limit($board);
if (!$bump_limit) {
return null;
}
$board_sql = mysql_real_escape_string($board);
if (!$board_sql) {
return null;
}
// Skip threads with modified options (manually archived, permasaged, etc)
$in_clause = $this->get_option_threads($board);
if (!empty($in_clause)) {
$in_clause = " AND no NOT IN (" . implode(',', $in_clause) . ")";
}
else {
$in_clause = '';
}
// Get last reply times for archived threads
$query = <<<SQL
SELECT IF(resto = 0, no, resto) as tid, MAX(IF(permasage = 0, time, 0)) as upd, COUNT(*) as cnt FROM `$board_sql`
WHERE resto IN(
SELECT no FROM `$board_sql`
WHERE archived = 1 AND resto = 0 AND permasage = 0 AND root > DATE_SUB(NOW(), INTERVAL $thres HOUR) $in_clause
)
OR no IN(
SELECT no FROM `$board_sql`
WHERE archived = 1 AND resto = 0 AND permasage = 0 AND root > DATE_SUB(NOW(), INTERVAL $thres HOUR) $in_clause
)
GROUP BY tid HAVING cnt <= $bump_limit
SQL;
$res = mysql_board_call($query);
if (!$res) {
$this->error('Database Error (gtts0)');
}
$threads = [];
$in_clause = [];
while ($row = mysql_fetch_assoc($res)) {
$in_clause[] = (int)$row['tid'];
$threads[$row['tid']] = (int)$row['upd'];
}
if (!empty($in_clause)) {
$in_clause = implode(',', $in_clause);
}
else {
// No archived threads
return null;
}
// Get thread archiving times
$query = <<<SQL
SELECT no, UNIX_TIMESTAMP(root) as arc FROM `$board_sql`
WHERE no IN($in_clause)
SQL;
$res = mysql_board_call($query);
if (!$res) {
$this->error('Database Error (gtts1)');
}
$ttl = [];
while ($row = mysql_fetch_assoc($res)) {
if (isset($threads[$row['no']])) {
$ttl[] = (int)$row['arc'] - $threads[$row['no']];
}
}
sort($ttl, SORT_NUMERIC);
$size = count($ttl);
$data = [];
$data['min_ttl'] = $ttl[0];
if ($size > 1) {
$perc = ceil($size * 0.01);
$data['min_ttl_low'] = $ttl[$perc];
}
else {
$data['min_ttl_low'] = $data['min_ttl'];
}
return $data;
}
/**
* Compiles moderation stats (deletions, bans, reports)
*/
private function get_moderation_stats($board) {
$actions_tbl = self::USER_ACTIONS_TABLE;
$del_log_tbl = self::DEL_LOG_TABLE;
$thres = (int)self::TIME_RANGE_2;
$data = [];
// ---
$query = <<<SQL
SELECT COUNT(*) as cnt FROM `$actions_tbl`
WHERE board = '%s'
AND action = 'report'
AND time > DATE_SUB(NOW(), INTERVAL $thres HOUR)
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$data['reports'] = (int)mysql_fetch_row($res)[0];
// ---
$query = <<<SQL
SELECT COUNT(*) as cnt FROM `$del_log_tbl`
WHERE board = '%s'
AND cleared = 0
AND ts > DATE_SUB(NOW(), INTERVAL $thres HOUR)
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$data['deletions'] = (int)mysql_fetch_row($res)[0];
return $data;
}
private function get_thread_size_stats($board) {
$thres = (int)self::TIME_RANGE;
$b = mysql_real_escape_string($board);
$query = <<<SQL
SELECT COUNT(*) as cnt FROM `$b` WHERE
resto IN(SELECT no FROM `$b` WHERE resto = 0 AND sticky = 0 AND (archived = 0 OR root > DATE_SUB(NOW(), INTERVAL $thres HOUR)))
GROUP BY resto
ORDER BY cnt ASC
SQL;
$res = mysql_board_call($query);
if (!$res) {
$this->error('Database Error (gtss1)');
}
$counts = [];
while ($row = mysql_fetch_row($res)) {
$counts[] = (int)$row[0];
}
$size = count($counts);
if (!$size) {
return array('med' => 0, 'avg' => 0);
}
$idx = floor($size / 2);
if ($size & 1) {
$median = $counts[$idx];
}
else {
$median = floor(($counts[$idx - 1] + $counts[$idx]) / 2);
}
$average = floor(array_sum($counts) / $size);
return array('med' => $median, 'avg' => $average);
}
/**
*
*/
private function get_post_stats($board) {
$actions_tbl = self::USER_ACTIONS_TABLE;
$actions_log_tbl = self::ACTIONS_LOG_TABLE;
$thres = (int)self::TIME_RANGE;
$thres_th_ttl = (int)self::TIME_RANGE_2;
$thres_ts = $_SERVER['REQUEST_TIME'] - self::TIME_RANGE * 3600;
$data = array();
// Count live and archived posts
foreach (array('live' => 0, 'archived' => 1) as $label => $val) {
$query = "SELECT COUNT(*) FROM `%s` WHERE archived = $val";
$res = mysql_board_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$data[$label . '_posts'] = (int)mysql_fetch_row($res)[0];
}
// Thread TTL before getting pruned
$in_clause = $this->get_option_threads($board);
if (!empty($in_clause)) {
$in_clause = " AND no NOT IN (" . implode(',', $in_clause) . ")";
}
else {
$in_clause = '';
}
$query = <<<SQL
SELECT
COUNT(*) as cnt,
MIN(UNIX_TIMESTAMP(root) - time) as min_ttl,
ROUND(AVG(UNIX_TIMESTAMP(root) - time)) as avg_ttl,
MAX(UNIX_TIMESTAMP(root) - time) as max_ttl
FROM `%s`
WHERE resto = 0 AND archived = 1 AND permasage = 0
AND root > DATE_SUB((SELECT root FROM `%s` WHERE archived = 1 ORDER BY root DESC LIMIT 1),
INTERVAL $thres_th_ttl HOUR)$in_clause
SQL;
$res = mysql_board_call($query, $board, $board);
if (!$res) {
$this->error('Database Error.');
}
$data['thread_ttl'] = mysql_fetch_assoc($res);
$_cnt = (int)$data['thread_ttl']['cnt'];
unset($data['thread_ttl']['cnt']);
if ($_cnt > 0) {
$_perc_low = ceil(($_cnt - 1) * 0.01);
$query = <<<SQL
SELECT (UNIX_TIMESTAMP(root) - time) as min_ttl
FROM `%s`
WHERE resto = 0 AND archived = 1 AND permasage = 0
AND root > DATE_SUB((SELECT root FROM `%s` WHERE archived = 1 ORDER BY root DESC LIMIT 1),
INTERVAL $thres_th_ttl HOUR)$in_clause
ORDER BY min_ttl ASC
LIMIT $_perc_low, 1
SQL;
$res = mysql_board_call($query, $board, $board);
if (!$res) {
$this->error('Database Error.');
}
$data['thread_ttl']['min_ttl_low'] = (int)mysql_fetch_row($res)[0];
}
// File type breakdown
$query = <<<SQL
SELECT COUNT(*) as cnt, ext FROM `%s`
WHERE ext != '' AND time >= $thres_ts
GROUP BY ext
SQL;
$res = mysql_board_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$file_types = array();
while ($row = mysql_fetch_row($res)) {
$file_types[$row[1]] = (int)$row[0];
}
$data['file_types'] = $file_types;
// Recent unique IPs
$data['unique_ips'] = $this->get_unique_ips_count($board);
// Image and text replies in the past TIME_RANGE hours
$query = <<<SQL
SELECT SUM(had_image) as img, SUM(had_image = 0) as txt
FROM `$actions_tbl`
WHERE action = 'new_reply' AND board = '%s'
AND time >= DATE_SUB(NOW(), INTERVAL $thres HOUR)
SQL;
$res = mysql_global_call($query, $board);
if (!$res) {
$this->error('Database Error.');
}
$row = mysql_fetch_assoc($res);
$data['image_replies'] = (int)$row['img'];
$data['text_replies'] = (int)$row['txt'];
return $data;
}
/**
* Index
*/
public function index() {
$this->is_manager = has_level('manager') || has_flag('developer');
if (isset($_GET['board'])) {
if ($this->is_manager) {
if ($_GET['board'] === 'global') {
$this->active_board = 'global';
$this->boards = $this->get_board_titles();
$this->renderHTML('stats-global');
return;
}
if ($_GET['board'] === 'monthly') {
$this->active_board = 'monthly';
$this->monthly();
return;
}
}
$this->stats_mode = true;
$raw_board = $_GET['board'];
$post_stats = $this->get_post_stats($raw_board);
$this->mod_stats = $this->get_moderation_stats($raw_board);
$this->posting_rate = $this->get_posting_rate($raw_board);
$data = array(
'replyTypes' => array(
'imageReplies' => $post_stats['image_replies'],
'textReplies' => $post_stats['text_replies']
),
'fileTypes' => $post_stats['file_types'],
'newThreads' => $this->posting_rate['threads'],
'newReplies' => $this->posting_rate['replies'],
);
if (isset($_GET['json'])) {
$data['livePosts'] = $post_stats['live_posts'];
$data['archivedPosts'] = $post_stats['archived_posts'];
$data['reports'] = $this->mod_stats['reports'];
$this->successJSON($data);
return;
}
$this->active_board = htmlspecialchars($raw_board);
$board_title = $this->get_board_titles($raw_board);
$this->page_title = " - /{$this->active_board}/ - $board_title";
$this->ttl_stats = $this->get_thread_ttl_stats($raw_board);
$this->thread_size_stats = $this->get_thread_size_stats($raw_board);
$this->plot_data = json_encode($data);
$this->post_stats = $post_stats;
if ($this->is_manager) {
$this->ua_stats = $this->get_mobile_ua_stats($raw_board);
}
else {
$this->ua_stats = null;
}
}
else {
$this->stats_mode = false;
$this->page_title = '';
}
$this->boards = $this->get_board_titles();
$this->renderHTML('stats');
}
private function monthly() {
$tbl = self::BOARD_STATS_TABLE;
$interval = new DateInterval('P1M');
$s_date = new DateTime();
$e_date = new DateTime();
$e_date->add($interval);
$data = array();
for ($i = 0; $i <= self::MAX_MONTHS; $i++) {
$s_clause = $s_date->format('Y-m-01 00:00:00');
$e_clause = $e_date->format('Y-m-01 00:00:00');
$query =<<<SQL
SELECT MIN(post_count) as post_count FROM $tbl
WHERE created_on BETWEEN '$s_clause' AND '$e_clause'
GROUP BY board
SQL;
$res = mysql_global_call($query);
if (!$res) {
$this->error('Database Error.');
}
$month_name = $s_date->format('M');
$total = 0;
while ($row = mysql_fetch_array($res)) {
$total += (int)$row[0];
}
$data[] = array($month_name, $total);
$s_date->sub($interval);
$e_date->sub($interval);
}
if (empty($data)) {
$this->error('Internal Server Error.');
}
$lim = count($data) - 1;
$data = array_reverse($data);
for ($i = 0; $i < $lim; $i++) {
$data[$i][1] = $data[$i + 1][1] - $data[$i][1];
}
array_pop($data);
$this->plot_data = json_encode($data);
$this->boards = $this->get_board_titles();
$this->renderHTML('stats-monthly');
}
private function sort_val_desc($a, $b) {
if ($a == $b) {
return 0;
}
return ($a > $b) ? -1 : 1;
}
public function spamtest() {
if (isset($_GET['type']) && $_GET['type']) {
$type = mysql_real_escape_string($_GET['type']);
}
else {
$type = 'blocked_g';
}
if (isset($_GET['nopost'])) {
$lim = 5000;
$cols = 'id, board, ip, created_on';
}
else {
$lim = 500;
$cols = '*';
}
$wc = strpos($type, '*');
if ($wc && $wc === (strlen($type) - 1)) {
$type = str_replace('*', '%', $type);
$type = str_replace('_', "\_", $type);
$type_sql = "LIKE '$type'";
}
else {
$type_sql = "= '$type'";
}
$query = "SELECT $cols FROM event_log WHERE type $type_sql ORDER BY id DESC LIMIT $lim";
$res = mysql_global_call($query);?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="referrer" content="never">
<title>4chan</title>
<link rel="stylesheet" type="text/css" href="/css/bans.css">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
</head>
<body>
<div id="content">
<table class="items-table compact-table">
<tr>
<th>ID</th>
<th>Type</th>
<th>Date</th>
<th>Board</th>
<th>TID</th>
<th>PID</th>
<th>IP</th>
<th>Num Arg</th>
<th>Str Arg</th>
<th>Meta</th>
</tr>
<?php while ($row = mysql_fetch_assoc($res)) { ?>
<tr>
<td class="cnt-pre"><?php echo $row['id'] ?></td>
<td class="cnt-pre"><?php echo $row['type'] ?></td>
<td class="cnt-pre"><?php echo $row['created_on'] ?></td>
<td class="cnt-pre"><?php echo $row['board'] ?></td>
<td class="cnt-pre"><?php echo $row['thread_id'] ?></td>
<td class="cnt-pre"><?php echo $row['post_id'] ?></td>
<td class="cnt-pre"><?php echo $row['ip'] ?></td>
<td class="cnt-pre"><?php echo $row['arg_num'] ?></td>
<td class="cnt-pre"><?php echo $row['arg_str'] ?></td>
<td class="cnt-pre"><?php echo $row['meta'] ?></td>
</tr>
<?php } ?>
</table>
</div>
</body>
</html>
<?php
}
/**
* Main
*/
public function run() {
$method = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET;
if (isset($method['action'])) {
$action = $method['action'];
}
else {
$action = 'index';
}
if (in_array($action, $this->actions)) {
$this->$action();
}
else {
$this->error('Bad request');
}
}
}
$ctrl = new App();
$ctrl->run();