ONO::DB

package ONO::DB;
################################################################################
# COPYRIGHT / LICENSE #
################################################################################
#
# This file is part of the ONO Software Project.
#
# Copyright (C) 2000-2025 Jos KIRPS [ www.kirps.com | jos_AT_kirps_DOT_com ]
# and The Joopita Project [ www.joopita.org | contact_AT_joopita_DOT_com ]
#
# This file, as well as other parts of the ONO Software Project or related
# elements, are FREE SOFTWARE available under the ARTISTIC LICENSE 2.0.
#
# This program 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.
#
# For the full license, see /ono/osr/license/LICENSE.txt, or write to
# jos_AT_kirps_DOT_com or contact_AT_joopita_DOT_com.
#
################################################################################
# END OF COPYRIGHT / LICENSE, HERE COMES THE CODE ... #
################################################################################


use strict;

use DBI;

use ONO::IO;

use ONO::Lib::DateTime::ToolBox;

use ONO::Lib::Encoding::ODBC;

use ONO::Lib::Web::BotDetect;
use ONO::Lib::Web::Client;
use ONO::Lib::Web::MaliciousIP;

###############################################################################
# ONO
###############################################################################

#: ONO_DB is responsible for all database. All DB related I/O should always be
#: handled by ONO_DB, you should not use DBI/DBD or any low level Perl DB I/O
#: stuff in your ONO projects.
#:
#: Important DB calls include:
#:
#; connect(), command(), select(), readcols()
#:
#: SQL databases should be listed in /etc/sql, note that database_name
#: doesn't need to be the actual name of the SQL database:
#:
#; /etc/sql/database_name.conf
#:
#: The content of the file looks more or less like this, please adjust to your
#: needs:
#:
#; driver mysql#;#host localhost#;#database your_db_name#;#username your_db_username#;#password your_db_password
#:
#: You may then connect to be database and execute commands:
#:
#; my $db = ONO_DB->connect('database_name');#;#ONO_DB->command($db,"UPDATE ...");
#:
#: To read data from a table:
#:
#; foreach my $line (ONO_DB->select($db,$table,$where,$order)) {#;#my @col = ONO_DB->readcols($line);#;#my $first_column_data = $col[0];#;#...#;#}

my $db_driver = "mysql";
if (ONO::IO->exists("etc/sql/drivers/odbc-mssql.conf")) {
$db_driver = "odbc";
}

my $debugger_mode;
if (ONO::IO->devstation || ONO::IO->exists("var/tmp/debug/ono_debugger.conf")) {
$debugger_mode = 1;
}

my $db_driver_odbc_encode_command = 0;
my $db_driver_odbc_encode_decode = 0;
my $db_driver_odbc_encode_readcols = 1;
my $db_driver_odbc_encode_get = 1;

###############################################################################
# LOCAL DEBUGGER
###############################################################################

# ask for user agent to prevent this on the command line:

if ($ENV{'HTTP_USER_AGENT'} && $debugger_mode) {
ONO::IO->mkpath("var/tmp/debug");
ONO::IO->rm("var/tmp/debug/ono_db.txt");
}

sub _debug {

#: Internal function used to log DB calls.

if ($debugger_mode) {
ONO::IO->append("var/tmp/debug/ono_db.txt","$_[0]\n");
}

}

sub _error {

#: Internal function used to log errors.

if ($debugger_mode) {
my $ERR = $_[0]->errstr;
if ($ERR) {
&_debug(qq~<span class="bold lightred">ONO_DB ERROR: $ERR</span>~);
}
}
}

###############################################################################
# DB API
###############################################################################

sub driver {

#: Returns the name / ID of the current driver, this will either be 'mysql'
#: or 'odbc'. MySQL is the default DB used by ONO, you may access Microsoft
#: SQL server databases by adding a file named
#: etc/sql/drivers/odbc-mssql.conf.
#:
#: Note that ONO cannot use MySQL and MS SQL simultaneously, you will need
#: to opt for one single solution at a time.

return $db_driver;

}

sub connect {

#: Connect to a database, and return a DB handle. Note that ONO can connect
#: to multiple databases at once, as lons as they use the same driver.
#:
#: Databases should be configured in /etc/sql (on the document root level,
#: NOT in the UNIX /etc directory), so that you can simply connect to
#: database_name by creating the file /etc/sql/database_name.conf and then
#: using the ONO_DB->connect('database_name') command.
#:
#: Alternatively, you may also use the -d switch to pass username and
#: password information directly to the connact() function.
#:
#: Switches:
#:
#: -d use username and password input (only DBI, not ODBC)
#: -u use username and password input (only DBI, not ODBC) (onsolete, use -d)
#: -s store status

my (
$self,
$database,
$switches,
$username,
$password,
) = @_;

my ($db_name,$db_user,$db_pass,$db_driver);

if ($switches =~ /d/) {

$db_name = $database;
$db_user = $username;
$db_pass = $password;
$db_driver = "dbi";

} else {

(
$db_name,
$db_user,
$db_pass,
$db_driver,
) = &read("",$database);

}

my $status = "ONO_DB connect [ok]\n";

if ($db_name && $db_user && $db_pass) {

$status .= "config is valid\n";

if ($db_driver =~ /odbc/i) {
$status .= "driver: ODBC\n";
eval "use DBD::ODBC";
if (!$@) {
$status .= "trying to connect...\n";
my $db = DBI->connect("dbi:ODBC:DSN=$db_name;UID=$db_user;PWD=$db_pass",'','',{ RaiseError => 1,AutoCommit => 1,odbc_utf8_on => 1});
$db->{LongTruncOk} = 1;
$db->{LongReadLen} = 4 * 4096 * 1024;
return $db;
}
} else {
$status .= "driver: MySQL\n";
eval "use DBD::mysql";
if (!$@) {
$status .= "trying to connect...\n";
my $db = DBI->connect("DBI:mysql:host=localhost:$db_name",$db_user,$db_pass);
return $db;
}
}
}

if ($switches =~ /s/) {
ONO::IO->store("root/db_status.txt",$status);
}
&_debug($status);

}

sub bad_sql_bot {

#: Returns 1 of ONO thinks that the web client is an evil web bot, or 0
#: if ONO thinks it's a human or a friendly bot.

if (ONO::Lib::Web::BotDetect->evil_bot()) {
return 1;
} else {
return 0;
}

}

sub bad_sql_ip {

#: Returns 1 of ONO thinks that the web client uses an evil IP, or 0
#: if ONO thinks that the IP doesn't look suspicious.

return ONO::Lib::Web::MaliciousIP->evil_ip_simple(ONO::Lib::Web::Client->ip());

}

sub read {

#: Read a configuration file in /etc/sql and return its data as an array.

my $database = $_[1];
my @confs = ONO::IO->load("etc/sql/$database.conf");

my ($db_name,$db_user,$db_pass,$db_driver);

foreach my $conf (@confs) {
if ($conf =~ /database (.*?)\n/ ) {$db_name = $1}
if ($conf =~ /username (.*?)\n/ ) {$db_user = $1}
if ($conf =~ /password (.*?)\n/ ) {$db_pass = $1}
if ($conf =~ /driver (.*?)\n/ ) {$db_driver = $1}
}

return ($db_name,$db_user,$db_pass,$db_driver);
}

sub confcheck {

#: Check if configuration data is complete.

my $conf_ref = $_[1];
my %conf = %$conf_ref;

if ($conf{'driver'} =~ /[A-Za-z0-9]/ &&$conf{'host'} =~ /[A-Za-z0-9]/ && $conf{'database'} =~ /[A-Za-z0-9]/ && $conf{'username'} =~ /[A-Za-z0-9]/ && $conf{'password'} =~ /[A-Za-z0-9]/) {
return 1;
} else {
return 0;
}

}

sub decode {

#: Decode data from UTF-8 to cp1252, which may be required on
#: ODBC installations in some cases.

my ($self,$data) = @_;

if ($db_driver eq "odbc" && $db_driver_odbc_encode_decode) {
eval "use Encode";
if (!$@) {
# Encode::from_to($data,'utf-8','cp1252');
}
}

return $data;

}

sub command {

#: Execute an SQL command. In case of INSERT, UPDATE or DELETE this
#: may also return data in array format.
#:
#: ONO::IO->command() is usually used to store, update or delete
#: data from tables. To read data, use select() and readcols().
#:
#: If $dump is true, then the status will be saved into a file named
#: __SQL_ERR_DUMP.txt on development stations.
#:
#: When storing binary data, ODBC may require $no_encode to be set to 1.
#: In this case, data may have be be decoded when reading later on.

my (
$self,
$db,
$command,
$dump,
$no_encode,
) = @_;

# no_encode is only for ODBC, if we need to store BINARY data or UNICODE / EMOJIS for example
# note that we'll need to decode when reading later on in this case though...

&_debug($command);

if ($db_driver eq "odbc") {
$command = ONO::Lib::Encoding::ODBC->encode($command);
if (!$no_encode && $db_driver_odbc_encode_command) {
eval "use Encode";
if (!$@) {
Encode::from_to($command,'utf-8','cp1252');
}
}
}

my @data;
my $stat = $db->prepare("$command;");
$stat->execute();

if ($command !~ /^(INSERT|UPDATE|DELETE) /) {
while (my @row = $stat->fetchrow_array()) {
@data = (@data,\@row);
}
}

&_error($stat);

if (ONO::IO->devstation && $dump) {
my $str = $stat->errstr;
ONO::IO->append("__SQL_ERR_DUMP.txt","$str:\n$command\n\n####\n");
}

$stat->finish();

return @data;

}

sub command_update {

#: Like command(), additionally this will automatically update
#: modification_username and modification_timestamp (will only
#: work with tables offering those).

my ($self,$db,$command,$user) = @_;

my $UPDATE = &update_timestamp_string("",$user);
$command =~ s~ WHERE ~ , $UPDATE WHERE ~;

&command("",$db,$command);

}

sub dump {

#: Make a backup of a specific MySQL database table. Note that this will
#: not work with ODBC databases.

my (
$self,
$vpath,$community,$table,$dir,
) = @_;

my ($db_name,$db_user,$db_pass) = &read("",$community);

`mysqldump $db_name $table --user=$db_user --password=$db_pass > $vpath/$dir/$table.sql`;

&_debug("dump: $vpath/$dir/$table.sql");

return "$vpath/$dir/$table.sql";

}

sub inc {

#: Increment the value of a database table column.

my (
$self,
$db,
$column,
$table,
$where,
) = @_;
$table = &clean("",$table);

if ($where && $where !~ /WHERE/gi) {
$where = "WHERE $where";
}

&_debug("UPDATE $table SET $column = $column + 1 $where;");

my $stat = $db->prepare("UPDATE $table SET $column = $column + 1 $where;");
$stat->execute();

return $stat->fetchrow_array();

$stat->finish();

}

sub tables {

#: Return a list of all tables contained in a database.

my ($self,$db,$driver) = @_;

&_debug("reading out tables...");

my (@tables,$found);

if ($driver eq "odbc" || $db_driver eq "odbc") {

# MS-SQL and ODBC code:

my $stat = $db->prepare("SELECT Distinct TABLE_NAME FROM information_schema.TABLES;");
$stat->execute();
while (my @row = $stat->fetchrow_array()) {
$found++;
@tables = (@tables,$row[0]);
}
$stat->finish();

} else {

# standard MySQL code:

my $stat = $db->prepare("SHOW TABLES;");
$stat->execute();
while (my @row = $stat->fetchrow_array()) {
$found++;
@tables = (@tables,$row[0]);
}
$stat->finish();

}

return sort @tables;

}

sub clean {

#: Clean up a table or column name. Mostly used internally by the
#: module, to make sure no bad code will be passed to functions.

my $code = $_[1];
$code =~ s~[^A-Za-z0-9\_\,]~~g;
return $code;
}

sub table_exists {

#: Check if a specific table exists in a database. Returns 1 if the
#: table exists, or 0 if it doesn't exist.

my ($self,$db,$table ) = @_;
$table = &clean("",$table);

my $exists;

foreach my $tab (&tables) {
if ($tab eq $table) {
$exists++;
}
}

return $exists;

}

sub copy_record {

#: Copy a record, which means that a table record will be duplicated, using
#: a new primary key.

my (
$self,
$db,
$table,
$primkey,
$old,
$new,
) = @_;
$table = &clean("",$table);
$primkey =~ s~[^A-Za-z0-9]~~g;
$old =~ s~[^A-Za-z0-9]~~g;
$new =~ s~[^A-Za-z0-9]~~g;

my $tmp_id = 1000+int(rand(999));
my $RET;

foreach my $COM (
"CREATE TEMPORARY TABLE tmptable_$tmp_id SELECT * FROM $table WHERE $primkey = '$old';",
"UPDATE tmptable_$tmp_id SET $primkey = '$new';",
"INSERT INTO $table SELECT * FROM tmptable_$tmp_id;",
"DROP TEMPORARY TABLE IF EXISTS tmptable_$tmp_id;",
) {
$RET .= "$COM<br>";
my $stat = $db->prepare($COM);
$stat->execute();
$stat->finish();
}

return $RET;

}

sub count {

#: Count the number of rows / records in a table.

my ($self,$db,$table,$options) = @_;
$table = &clean("",$table);

if ($options && $options !~ /WHERE/gi) {
$options = "WHERE $options";
}

&_debug("SELECT count(*) FROM $table $options;");

my $stat = $db->prepare("SELECT count(*) FROM $table $options;");
$stat->execute();

my $count = $stat->fetchrow_array();

$stat->finish();

if ($count < 1) {
return 0;
} else {
return $count;
}

}

sub select {

#: Read data from a table, which should always be used along with the
#: readcols() function. $select will filter results, $order can be used
#: to sort results, $limitoffset allows to limit the number of rows
#: returned an to set an offset.
#:
#: If debug is true, then select() will not return the data, but the
#: generated SQL command.

my (
$self,
$db,
$table,
$select,
$order,
$limitoffset,
$debug,
) = @_;

if (&bad_sql_bot || &bad_sql_ip) {

return ();

} else {

my $columns = "*";
if ($table =~ s~:(.*?)$~~) {
$columns = $1;
$columns =~ s~[^A-Za-z0-9\_\,]~~g;
}

$table = &clean("",$table);

my $INPUT = qq~"$table","$select","$order","$limitoffset"~;
my $WHERE = &_where($select);

my ($ORDER,$LIMIT);
if ($order =~ /[A-Za-z0-9]/) {
$ORDER = $order;

# ms-sql / odbc exceptions:
# RAND() becomes NEWID()

if ($db_driver eq "odbc") {
$ORDER =~ s~RAND\(\)~NEWID\(\)~gi;
}

if ($ORDER !~ /ORDER/) {
$ORDER = "ORDER BY $ORDER";
}
}

if ($limitoffset) {
$LIMIT = $limitoffset;
}

# mysql format:
# LIMIT 10 OFFSET 10

# ms-sql / odbc format:
# ORDER BY * OFFSET 10 ROWS FETCH NEXT 10 ROWS

if ($db_driver eq "odbc" && $limitoffset) {
$limitoffset .= " ";

if ($limitoffset =~ /LIMIT (.*?) OFFSET (.*?) /) {

$LIMIT = "OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY";

} else {

$limitoffset =~ s~LIMIT (.*?) ~~;
if ($limitoffset < 1) {
$limitoffset = 0;
}
if ($1) {
$LIMIT = "OFFSET $limitoffset ROWS FETCH NEXT $1 ROWS ONLY";
}
}

if (!$ORDER && $LIMIT) {
$ORDER = "ORDER BY 1";
}
}

my @records;

&_debug("SELECT $columns FROM $table $WHERE $ORDER $LIMIT;");

my $stat = $db->prepare("SELECT $columns FROM $table $WHERE $ORDER $LIMIT;");

if ($db_driver eq "odbc") {

$stat->{LongTruncOk} = 1;
$stat->{LongReadLen} = 4 * 4096 * 1024;

}

$stat->execute();

while (my @row = $stat->fetchrow_array()) {

# if ($db_driver =~ /odbc/i) {
#
# eval "use Encode";
# if (!$@) {
#
# foreach my $col (@row) {
# $col = ONO::Lib::Encoding::ODBC-decode($col);
# if ($col =~ /(Ã)/) {
# Encode::from_to($col,'utf-8','cp1252');
# }
# }
#
# }
#
# }

@records = (@records,\@row);
}

$stat->finish();

if ($debug) {

return qq~SELECT $columns FROM $table $WHERE $ORDER $LIMIT; ($INPUT)~;

} else {

return @records;

}

}

}

sub readcols {

#: Will turn the output of select() into readable arrays.

my $line = $_[1];

if ($db_driver !~ /odbc/i) {

# mysql just returns the line data

return (@$line);

} else {

# microsoft sql fix - don't know why empty spaces are returned from NULL cols,
# but we need to get rid of them…

my @newline;

eval "use Encode";
if (!$@) {

foreach my $col (@$line) {
$col = ONO::Lib::Encoding::ODBC->decode($col);
if ($col =~ /(Ã)/ && $db_driver_odbc_encode_readcols) {
Encode::from_to($col,'utf-8','cp1252');
}
@newline = (@newline,$col);
}

}

return (@newline);

# return (@$line);

}

}

sub get {

#: Returns a specific column from a specific row.

my ($self,$db,$column,$table,$where,$order,$limit) = @_;
$table = &clean("",$table);
$column =~ s~[^A-Za-z0-9\_\-\,]~~g;
if ($where && $where !~ /WHERE/gi) {
$where = "WHERE $where";
}

# ms-sql / odbc exceptions:
# RAND() becomes NEWID()

if ($db_driver eq "odbc") {
$order =~ s~RAND\(\)~NEWID\(\)~gi;
}

if ($order && $order !~ /ORDER BY/) {
$order = "ORDER BY $order";
}

if ($db_driver eq "odbc") {
if ($limit =~ /[0-9]/) {
$limit =~ s~LIMIT ~~;
$limit = "OFFSET 0 ROWS FETCH NEXT $limit ROWS ONLY";
}
} else {
if ($limit =~ /[0-9]/ && $limit !~ /LIMIT/) {
$limit = "LIMIT $limit";
}
}

&_debug("SELECT $column FROM $table $where;");

if ($column =~ /\,/) {

my $stat = $db->prepare("SELECT $column FROM $table $where $order $limit;");
$stat->execute();

if ($db_driver eq "odbc") {

# this is a dirty fix, as it only checks the first 3 columns for encoding problems

my @data = $stat->fetchrow_array();

if ($db_driver_odbc_encode_get) {
for (my $i = 0; $i < 9; $i++) {
$data[$i] = ONO::Lib::Encoding::ODBC->decode($data[$i]);
if ($data[$i] =~ /(Ã)/) {
Encode::from_to($data[$i],'utf-8','cp1252');
}
}
}

return @data;

} else {

return $stat->fetchrow_array();

}

$stat->finish();

} else {

my $stat = $db->prepare("SELECT $column FROM $table $where $order $limit;");
$stat->execute();

my $data = $stat->fetchrow_array();

$stat->finish();

if ($db_driver eq "odbc") {
$data = ONO::Lib::Encoding::ODBC->decode($data);
if ($data =~ /(Ã)/ && $db_driver_odbc_encode_get) {
Encode::from_to($data,'utf-8','cp1252');
}
}

return $data;

}

}

sub get_highest_primkey {

#: Detect the highest primary key in a table.
#: Also see get_next_primkey() and get_auto_inc().

my (
$self,
$db,
$primkey,
$table,
$where,
) = @_;
$table = &clean("",$table);
my $WHERE = &_where($where);

$primkey =~ s~[^A-Za-z0-9\_\-\,]~~g;

my $return = 0;

my $stat = $db->prepare("SELECT $primkey FROM $table $WHERE;");
$stat->execute();

while (my @row = $stat->fetchrow_array()) {
if ($row[0] > $return) {
$return = $row[0];
}
}

$stat->finish();

return $return;

}

sub get_next_primkey {

#: Detect the next primary key that can be used in a table.
#: Also see get_highest_primkey() and get_auto_inc().

return &get_highest_primkey("",$_[1],$_[2],$_[3])+1;

}

sub get_auto_inc {

#: Get the next automatic increment id from a table.
#: Also see get_highest_primkey() and get_next_primkey().

my ($self,$db,$table) = @_;
$table = &clean("",$table);

my $stat = $db->prepare("SELECT AUTO_INCREMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table';");
$stat-> execute();
return $stat->fetchrow_array();

}

sub update_timestamp_string {

#: Generated the string required to update username and timestamp in
#: ONO standard SQL tables.

my $user = lc $_[1];
my $timestamp = time();

$user =~ s~[^a-z0-9]~~g;

return "modification_username = '$user', modification_timestamp = '$timestamp'";

}

sub update_timestamp {

#: Update columns modification_username and modification_timestamp
#: with current data.

my ($self,$db,$table,$record,$user) = @_;
$table = &clean("",$table);

if ($table && $record =~ /=/ && $user) {

my $WHERE = &_where($record);

my $TIME = &update_timestamp_string("",$user);

my $command = qq~UPDATE $table SET $TIME $WHERE;~;

my $stat = $db->prepare("$command");
$stat->execute();
$stat->finish();

&_debug($command);

return $command;

}

}

sub delete {

#: Delete a table row / record.

my ($self,$db,$table,$where) = @_;
$table = &clean("",$table);

if ($where =~ /=/) {

my $WHERE = &_where($where);

my $command = qq~DELETE FROM $table $WHERE;~;

my $stat = $db->prepare("$command");
$stat->execute();
$stat->finish();

&_debug($command);

return $command;

}

}

sub describe_table {

#: Return the names of all table columns as an array.
#: The driver (MySQL or ODBC) is being automatically detected,
#: but you may also force using ODBC by indicating it manually.

my ($self,$db,$table,$driver) = @_;

$table =~ s~[^A-Za-z0-9\_]~~g;

my $SQL = "DESCRIBE $table";
if ("$driver$db_driver" =~ /odbc/i) {
$SQL = "SELECT COLUMN_NAME,DATA_TYPE,CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_NAME = '$table' ORDER BY ORDINAL_POSITION";
}

&_debug($SQL);

my @columns;

my $stat = $db->prepare($SQL);
$stat->execute();
while (my @row = $stat->fetchrow_array()) {
@columns = (@columns,\@row);
}
$stat->finish();

return @columns;

}

sub connection_error {

#: Error message to be displayed in case of a connection error.

my ($self,$db,$mode,$lang,$test) = @_;
my $ERR;

if (!$db || $test) {

$mode = "database_connection_error";
$ERR = qq~<div class="box_red w80 auto">
<h2>Ooops...</h2>
<div class="box_paper large">
<div class="mb10">I seems that we're experiencing technical difficulties right now...</div>
<div class="mb10 bold">Please try again in a few minutes - thanks !</div>
<div>Sorry for the inconvenience :/</div>
</div>
<div class="large mt10">Error: ONO/DB connection failure</div>
</div>
~;

}

return ($ERR,$mode);

}

sub query_umlaut {

#: Umlaut compatibility for ODBC databases.
#: Simply processes and returns the string.

my $query = $_[1];

if ($db_driver eq "odbc") {

$query =~ s~(Ä|Ë|Ü|Ö|ä|à|â|ë|é|è|ê|ü|û|ö|ô|ï|î|ç|ß)~__~g;

}

return $query;

}

sub _where {

#: Internal function used to pre-process WHERE clause data.

my $select = $_[0];
$select =~ s~ =~=~g;
$select =~ s~= ~=~g;

my $WHERE;

if ($select =~ /=/ && $select =~ /\,/ && $select !~ / IN\(/) {

$select = qq~ AND $select~;
my @parts = split(/ AND /,$select);
foreach my $part (@parts) {

if ($part =~ /=/) {

my @sel_parts = split(/=/,$part);
$sel_parts[0] =~ s~('|`)~~g;
$sel_parts[1] =~ s~('|`)~~g;

$WHERE .= qq~ AND $sel_parts[0] = '$sel_parts[1]'~;
}
}
$WHERE =~ s~ AND ~ WHERE ~;
} else {
$WHERE = $select;
}

if ($WHERE =~ /[A-Za-z0-9]/ && $WHERE !~ /WHERE/) {
$WHERE = "WHERE $WHERE";
}

return $WHERE;

}

###############################################################################
# end of script
###############################################################################

1;

__END__