addChunk('Settings', function() use ($ui)
{
// First some settings...
$dumpvars = array('mbname', 'boardurl', 'boarddir', 'db_server', 'db_name', 'db_prefix');
$settings = array();
$settings[0] = array('Variable','Value');
foreach($dumpvars AS $var)
{
if ($ui->getSettingsFileVal($var) == null)
$value = 'NOT SET';
else
$value = $ui->getSettingsFileVal($var);
$settings[] = array($var, $value);
}
$ui->dumpTable($settings);
// Some settings table stuff...
$settings = array();
$settings[0] = array('Variable','Value');
foreach (array('smfVersion', 'attachmentCheckExtensions', 'attachmentDirSizeLimit', 'attachmentEnable', 'attachmentExtensions', 'attachmentNumPerPostLimit', 'attachmentPostLimit', 'attachmentShowImages', 'automanage_attachments', 'attachmentSizeLimit', 'basedirectory_for_attachments', 'attachment_basedirectories', 'attachmentUploadDir', 'custom_avatar_dir', 'currentAttachmentUploadDir', 'attachments_21_done', 'json_done') AS $var)
{
if ($ui->getSetting($var) == null)
$settings[] = array($var, 'NOT SET');
else
$settings[] = array($var, $ui->getSetting($var));
}
$ui->dumpTable($settings);
});
$ui->addChunk('Attachment Directories - attachmentUploadDir Decoded', function() use ($ui)
{
// Only for 2.1 & 3.0
if (!isset($ui->smfVersion) || !in_array($ui->smfVersion, array('2.1', '3.0')))
{
$ui->addError('This utility only works for SMF 2.1 & 3.0.');
return;
}
// Decode as array
$att_dir_json = $ui->getSetting('attachmentUploadDir');
$att_dirs = json_decode($att_dir_json, true);
if (empty($att_dirs))
$att_dirs = array();
$folders = array();
$folders[-1] = array('Folder ID', 'Folder', 'Valid Folder?');
foreach ($att_dirs as $num => $dir)
{
$valid = file_exists($dir) && is_dir($dir);
$folders[] = array($num, $dir, $valid ? 'True' : 'False');
}
$ui->dumpTable($folders);
// Lop off the header & Save this off for lookups later...
unset($folders[-1]);
$ui->att_dirs = array();
foreach ($folders as $folder)
$ui->att_dirs[$folder[0]] = strtr($folder[1], '\\', '/');
// Show count of attachments by folder
$counts = array();
$counts[] = array('Folder ID', 'Attachment Subtype', 'Count in DB');
$result = $ui->db->query('
SELECT id_folder,
CASE WHEN id_member = 0 THEN \'Attachment\'
ELSE \'Avatar Attachment\' END AS att_subtype,
count(*) as att_count
FROM ' . $ui->db->db_prefix . 'attachments
WHERE attachment_type != 1
GROUP BY id_folder, att_subtype
ORDER BY id_folder, att_subtype'
);
while ($row = $ui->db->fetch_assoc($result))
{
$row['att_count'] = number_format($row['att_count']);
$counts[] = $row;
}
$ui->db->free($result);
$ui->dumpTable($counts);
echo 'Avatars excluded.
';
});
$ui->addChunk('Attachment Directories - File System', function() use ($ui)
{
// Only for 2.1 & 3.0
if (!isset($ui->smfVersion) || !in_array($ui->smfVersion, array('2.1', '3.0')))
return;
// Recursively return all directories under boarddir that have 'att' somewhere in dir name...
function inspect_dir($dir, &$result)
{
$files = 0;
$bytes = 0;
foreach (glob($dir . '/*') as $entry)
{
if (is_dir($entry))
{
$result[] = inspect_dir($entry, $result);
}
else
{
$filename = basename($entry);
if ($filename == 'index.php')
continue;
if (substr($filename, 0, 1) == '.')
continue;
$files++;
$bytes += filesize($entry);
}
}
$files = number_format($files);
$bytes = number_format($bytes);
return array($dir, $files, $bytes);
}
$folders = array();
$folders[0] = array('Folders Found', 'Files', 'Size');
foreach (glob($ui->getSettingsFileVal('boarddir') . '/att*', GLOB_ONLYDIR) as $dir)
$folders[] = inspect_dir($dir, $folders);
$ui->dumpTable($folders);
echo 'index.php & files starting with a \'.\' are excluded.
';
});
$ui->addChunk('Comparing DB to File System', function() use ($ui)
{
// Only for 2.1 & 3.0
if (!isset($ui->smfVersion) || !in_array($ui->smfVersion, array('2.1', '3.0')))
return;
// Step 1: Get att info from db... Exclude avatars...
$result = $ui->db->query('
SELECT id_attach, id_msg, filename, file_hash, size, id_folder, \'\' AS lookup FROM ' . $ui->db->db_prefix . 'attachments
WHERE attachment_type != 1'
);
$db_atts = array();
while ($row = $ui->db->fetch_assoc($result))
{
$row['size'] = number_format($row['size']);
$db_atts[$row['id_attach']] = $row;
}
$ui->db->free($result);
// Step 2: Get att info from file system...
// Recursively return all files under all directories under boarddir that have 'att' somewhere in dir name...
function inspect_files($dir, &$fs_atts)
{
foreach (glob($dir . '/*') as $entry)
{
if (is_dir($entry))
inspect_files($entry, $fs_atts);
else
{
$filename = basename($entry);
if ($filename == 'index.php')
continue;
if (substr($filename, 0, 1) == '.')
continue;
if ($pos = strpos($filename, '_'))
$id = (int) substr($filename, 0, $pos);
else
// Use whole fs entry, including dir, to ensure unique, even across folders, when attach id not found
$id = $entry;
// The key must include the entry to handle dupes w/same attach id; the index is the fs data
$fs_atts[$id][$entry] = '';
}
}
}
$fs_atts = array();
foreach (glob($ui->getSettingsFileVal('boarddir') . '/att*', GLOB_ONLYDIR) as $dir)
inspect_files($dir, $fs_atts);
// Step 3: Merge these two arrays
$all_keys = array_merge(array_keys($db_atts), array_keys($fs_atts));
$all_keys = array_unique($all_keys, SORT_NATURAL);
asort($all_keys, SORT_NATURAL);
$all_atts = array();
$all_atts[0] = array('id_attach', 'id_msg', 'filename', 'file_hash', 'size (db)', 'id_folder', 'folder lookup', 'fs_folder', 'fs_filename', 'Error');
// Step thru keys now, might be fs &/or db
foreach ($all_keys as $id_attach)
{
if (key_exists($id_attach, $fs_atts))
{
// Possibly multiple files per attach, gotta loop
foreach ($fs_atts[$id_attach] as $file => $dummy)
{
$fs_folder = strtr(dirname($file), '\\', '/');
$fs_file = basename($file);
$right = array($fs_folder, $fs_file);
if (!key_exists($id_attach, $db_atts))
{
// No attach, orphan file (we know it's only one because use used full fs entry, including dir, as key)
$left = array_fill(0, 7, ' - ');
$err = 'No attachment record';
$all_atts[] = array_merge($left, $right, array($err));
// Bail, because the remaining edits compare to DB vals...
continue;
}
// Ensure folder ID'd by id_folder matches what we see in the file system...
$err = '';
$left = $db_atts[$id_attach];
$left['lookup'] = isset($ui->att_dirs[$left['id_folder']]) ? $ui->att_dirs[$left['id_folder']] : 'Invalid folder reference';
if ($fs_folder != $left['lookup'])
$err .= 'Incorrect folder';
// Filename check...
if ($fs_file != $id_attach . '_' . $db_atts[$id_attach]['file_hash'] . '.dat')
$err .= (empty($err) ? '' : '; ') . 'Invalid filename';
// Allow for adding 'dispall' to the URL to display all, including OK entries
if (!empty($err) || isset($_REQUEST['dispall']))
$all_atts[] = array_merge($left, $right, array($err));
}
}
else
{
// DB key only - missing file (we know it's only one because attach ids are unique)
$left = $db_atts[$id_attach];
$left['lookup'] = isset($ui->att_dirs[$left['id_folder']]) ? $ui->att_dirs[$left['id_folder']] : 'Invalid folder reference';
$right = array_fill(0, 2, ' - ');
$err = 'Missing file';
$all_atts[] = array_merge($left, $right, array($err));
}
}
// Step 4: Display results... Or dump to .csv...
if ((count($all_atts) == 1) && !isset($_REQUEST['dispall']))
echo '
No errors found!
';
else
{
if (isset($_REQUEST['csv']))
{
// Give it a unique timestamp...
$ts = date('YmdHis');
$csv_file = $ui->getSettingsFileVal('boarddir') . '/smf_attdir_' . $ts. '.csv';
$csv_url = $ui->getSettingsFileVal('boardurl') . '/smf_attdir_' . $ts. '.csv';
$fp = fopen($csv_file, 'w');
foreach ($all_atts as $row)
fputcsv($fp, $row, ",", '"', '');
fclose($fp);
echo '
CSV file created for download: ' . $csv_url . '
';
}
else
$ui->dumpTable($all_atts);
}
});
$ui->go();
/**
* SimpleSmfUI
*
* A simple basic abstracted UI for utilities.
*
* Copyright 2021-2025 Shawn Bulen
*
* This file is part of the sjrbTools library.
*
* SimpleSmfUI is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* SimpleSmfUI is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with SimpleSmfUI. If not, see .
*
*/
// Create a minimal db layer...
class Ssui_Db
{
/*
* Properties
*/
public $db_obj = null;
// Helps handle pg_connect errors...
public $pg_connect_error = '';
public $db_type = '';
public $db_prefix = '';
public $db_name = '';
/**
* Constructor
*
* Builds a SimpleSmfUI object
*
* @param string title
* @param bool db_needed
* @return void
*/
function __construct($db_type, $db_prefix, $db_character_set, $db_server, $db_user, $db_passwd, $db_name, $db_port)
{
// Some quick db parameter validations...
$this->db_type = $db_type == 'postgresql' ? 'postgresql' : 'mysql';
$this->db_prefix = empty($db_prefix) ? 'smf_' : $db_prefix;
$this->db_name = empty($db_name) ? '' : $db_name;
// pg...
if ($this->db_type == 'postgresql')
{
// Since pg_connect doesn't feed error info to pg_last_error, we have to catch issues with a try/catch.
set_error_handler(
function($errno, $errstr)
{
throw new ErrorException($errstr, $errno);
}
);
try
{
$this->db_obj = @pg_connect((empty($db_server) ? '' : 'host=' . $db_server . ' ') . 'dbname=' . $db_name . ' user=\'' . $db_user . '\' password=\'' . $db_passwd . '\'' . (empty($db_port) ? '' : ' port=\'' . $db_port . '\''));
}
catch (Exception $e)
{
// Make error info available to calling processes
$this->pg_connect_error = $e->getMessage();
$this->db_obj = null;
}
restore_error_handler();
}
// mysql...
else
{
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$this->db_obj = new mysqli($db_server, $db_user, $db_passwd, $db_name, $db_port);
if (!$this->db_obj->connect_errno)
{
// Set names...
if (!empty($db_character_set))
$this->db_obj->set_charset($db_character_set);
$this->db_obj->query('SET SESSION sql_mode = \'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,PIPES_AS_CONCAT\'');
}
}
}
/**
* query
*
* @param string query
* @return pgsql\result | mysqli_result
*/
public function query($query_string)
{
// pg...
if ($this->db_type == 'postgresql')
{
return pg_query($this->db_obj, $query_string);
}
// mysql...
else
{
return $this->db_obj->query($query_string);
}
}
/**
* fetch_assoc
*
* @param pgsql\result | mysqli_result
* @return array
*/
public function fetch_assoc($db_result)
{
// pg...
if ($this->db_type == 'postgresql')
{
return pg_fetch_assoc($db_result);
}
// mysql...
else
{
return $db_result->fetch_assoc();
}
}
/**
* free
*
* @param pgsql\result | mysqli_result
* @return void
*/
public function free($db_result)
{
// pg...
if ($this->db_type == 'postgresql')
{
pg_free_result($db_result);
}
// mysql...
else
{
$db_result->free();
}
}
/**
* escape_string
*
* @param string string
* @return string
*/
public function escape_string($string)
{
// pg...
if ($this->db_type == 'postgresql')
{
return pg_escape_string($this->db_obj, $string);
}
// mysql...
else
{
return $this->db_obj->real_escape_string($string);
}
}
/**
* connect_error
*
* @return string
*/
public function connect_error()
{
// pg...
if ($this->db_type == 'postgresql')
{
return $this->pg_connect_error;
}
// mysql...
else
{
return $this->db_obj->connect_error;
}
}
/**
* error
*
* @return string
*/
public function error()
{
// pg...
if ($this->db_type == 'postgresql')
{
return pg_last_error($this->db_obj);
}
// mysql...
else
{
return $this->db_obj->error;
}
}
}
// This oughtta hold us off until php 9.0...
#[\AllowDynamicProperties]
class SimpleSmfUI
{
/*
* Properties
*/
protected $site_title = 'Simple UI';
protected $max_width = 1200;
protected $db_needed;
protected $txt = array(
'err_no_title' => 'Site title is required and must be a string!',
'err_width' => 'Funky width specified!',
'err_no_settings' => 'Could not find Settings.php! Place this file in the same folder as Settings.php.',
'err_no_db' => 'Could not establish connection with the database!',
'err_no_chunk_title' => 'Invalid chunk title!',
'err_no_chunk_func' => 'Invalid chunk function!',
'errors' => 'Errors',
);
protected $chunks = array();
protected $errors = array();
public $db = null;
/*
* SMF Properties
*/
// From SMF Settings.php
public $settings_file;
// From smf_settings table
public $settings;
// Three byte version (2.1, 3.0) is handy...
public $smfVersion;
/**
* Constructor
*
* Builds a SimpleSmfUI object
*
* @param string title
* @param bool db_needed
* @return void
*/
function __construct($title, $db_needed = null, $max_width = 800)
{
// Might as well try...
@set_time_limit(6000);
@ini_set('memory_limit', '512M');
// Title...
if (is_string($title))
$this->site_title = $title;
else
$this->addError('err_no_title');
// db_needed...
if (empty($db_needed))
$this->db_needed = false;
else
$this->db_needed = true;
// Width...
if (is_numeric($max_width))
$this->max_width = $max_width;
else
$this->addError('err_width');
// Error handler
// Note that php error suppression - @ - still calls the error handler. It will return 0 as it does so (pre php8).
// Note error handling in php8+ no longer fails silently on many errors, but error_reporting()
// will return 4437 (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE)
// as it does so.
set_error_handler(
function($errno, $errstr, $errfile, $errline)
{
if ((error_reporting() != 0) && (error_reporting() != (E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE)))
$this->addError($errstr . ' (' . $errno . ')' . (empty($errfile) ? '' : ' ' . $errfile) . (empty ($errline) ? '' : ':' . $errline));
// Always try & report errors gracefully...
return true;
}
);
// DB...
define('SMF', 1);
define('SMF_VERSION', '2.x');
define('SMF_FULL_VERSION', 'SMF ' . SMF_VERSION);
define('SMF_SOFTWARE_YEAR', '2021');
define('POSTGRE_TITLE', 'PostgreSQL');
define('MYSQL_TITLE', 'MySQL');
define('SMF_USER_AGENT', 'Mozilla/5.0 (' . php_uname('s') . ' ' . php_uname('m') . ') AppleWebKit/605.1.15 (KHTML, like Gecko) SMF/' . strtr(SMF_VERSION, ' ', '.'));
$this->settings_file = array();
$this->settings = array();
if ($this->db_needed)
{
// Load & save off settings file contents
if (file_exists('Settings.php'))
{
include_once('Settings.php');
$dumpvars = array('mbname', 'db_server', 'db_name', 'db_prefix', 'db_type', 'db_character_set', 'db_mb4', 'language',
'boardurl', 'boarddir', 'sourcedir', 'packagesdir', 'tasksdir', 'cachedir',
'maintenance', 'mtitle', 'mmessage',
'cookiename', 'db_persist', 'db_error_send',
'cache_accelerator', 'cache_enable', 'cache_memcached',
'image_proxy_enabled', 'image_proxy_secret', 'image_proxy_maxsize');
foreach($dumpvars as $setting)
$this->settings_file[$setting] = (isset(${$setting}) ? ${$setting} : 'NOT SET');
// Make the connection...
$db_type = empty($db_type) ? 'mysql' : $db_type;
$db_port = empty($db_port) ? null : $db_port;
$db_character_set = empty($db_character_set) ? '' : $db_character_set;
$this->db = new Ssui_Db($db_type, $db_prefix, $db_character_set, $db_server, $db_user, $db_passwd, $db_name, $db_port);
if ($this->db->connect_error())
{
$this->addError('err_no_db', ' ' . $this->db->connect_error());
// So subsequent steps know the DB isn't there...
$this->db = null;
}
else
{
// Save off settings table contents...
$result = $this->db->query('SELECT * FROM ' . $db_prefix . 'settings');
while ($row = $this->db->fetch_assoc($result))
$this->settings[$row['variable']] = $row['value'];
// Save the 3-char version off, it's handy...
if (isset($this->settings['smfVersion']))
$this->smfVersion = substr($this->settings['smfVersion'], 0, 3);
}
}
else
$this->addError('err_no_settings');
}
}
/**
* Render chunk
*
* Display one portion of the form
*
* @return void
*/
protected function doChunk($ix, $chunk)
{
echo '';
}
/**
* Display errors
*
* Display errors in current display area
*
* @return void
*/
protected function renderErrors()
{
echo '
' . $this->txt['errors'] . '
';
foreach ($this->errors AS $error)
echo $error . '
';
echo '
';
}
/**
* Render header
*
* Spits out the head, title, style & starts the body
*
* @return void
*/
protected function renderHeader()
{
echo '
' . $this->site_title . '
';
}
/**
* Render header
*
* Closes out the body & html tags
*
* @return void
*/
protected function renderFooter()
{
// Close out body & html tags
echo 'Remove when not in use
';
echo 'sbulen/sjrbTools
';
echo '
';
}
/**
* Cleanse text
*
* Some basic hygiene for user-entered input
*
* @param string input
* @param bool gtlt - whether to leave > and < alone (e.g., for queries)
* @return string cleansed
*/
public function cleanseText($input, $gtlt = false)
{
$input = trim($input);
$input = htmlspecialchars($input);
if ($gtlt)
{
$input = str_replace('>', '>', $input);
$input = str_replace('>', '>', $input);
$input = str_replace('<', '<', $input);
$input = str_replace('<', '<', $input);
}
return $input;
}
/**
* Dump table
*
* Render a simple 2-d array in table form
*
* @param array passed_array
* @return void
*/
public function dumpTable($passed_array)
{
static $special_cells = array('NOT SET', 'null', 'true', 'false');
$header = true;
echo '
';
foreach($passed_array as $row)
{
// Some cleansing...
foreach ($row AS $ix => $cell)
{
// Treat NOT SET, null, true, & false special...
if (in_array($cell, $special_cells))
$row[$ix] = $cell;
else
{
$row[$ix] = htmlspecialchars($cell);
// Undo any line breaks you just broke...
$row[$ix] = str_replace('<br>', '
', $row[$ix]);
$row[$ix] = str_replace('<br />', '
', $row[$ix]);
}
}
if ($header)
echo '
';
}
/**
* Add Chunk
*
* Adds an entry to the internal chunk array.
* Each chunk will display a header, do some logic, & display some content.
* If errors are encountered, ideally they should be added to the errors display and displayed at the end.
*
* @param string title - title to display above this chunk
* @param function logic - what to execute, passed as an anonymous function
* @return void
*/
public function addChunk($title, $func)
{
if (!is_string($title))
{
$title = '';
$this->addError('err_no_chunk_title');
}
if (!is_callable($func))
{
$func = function() {};
$this->addError('err_no_chunk_func');
}
$this->chunks[] = array('title' => $title, 'function' => $func);
}
/**
* Get Settings File contents as array
*
* @return array
*/
public function getSettingsFile()
{
return $this->settings_file;
}
/**
* Get Settings File specific value
*
* @param string setting
* @return string
*/
public function getSettingsFileVal($setting)
{
if (isset($this->settings_file[$setting]))
$value = $this->settings_file[$setting];
else
$value = null;
return $value;
}
/**
* Get Settings table value
*
* @param string setting
* @return string
*/
public function getSetting($setting)
{
if (isset($this->settings[$setting]))
$value = $this->settings[$setting];
else
$value = null;
return $value;
}
/**
* Add Error
*
* Add error to internal log
*
* @param string key - is key to $txt array
* @param string more - is additional info to be added to output string if needed
* @return void
*/
public function addError($key, $more = '')
{
if (!is_string($key))
$key = '';
if (!is_string($more))
$more = '';
if (!empty($this->txt[$key]))
$key = $this->txt[$key];
$this->errors[] = $key . ' ' . $more;
}
/**
* Go
*
* Got everything, now do it...
*
* @return void
*/
public function go()
{
// Responding to a POST? Cleanse info, put in session and redirect
session_start();
if ($_POST)
{
$_SESSION = array();
foreach($_POST as $var => $val)
$_SESSION[$this->cleanseText($var)] = $this->cleanseText($val);
// Redirect to this page
header("Location: {$_SERVER['REQUEST_URI']}", true, 302);
exit();
}
// OK, display stuff...
$this->renderHeader();
// Execute the chunks...
// Note if db_needed & no connection, do not process chunks, just display the errors
if (!$this->db_needed || ($this->db_needed && !empty($this->db)))
{
foreach($this->chunks AS $ix => $chunk)
$this->doChunk($ix, $chunk);
}
// Display any errors...
if (!empty($this->errors))
$this->renderErrors();
$this->renderFooter();
// Ensure refreshes actually refresh!
$_SESSION = array();
}
}