diff --git a/classes/Database/DatabaseController.php b/classes/Database/DatabaseController.php index b3d1aac..153fb15 100644 --- a/classes/Database/DatabaseController.php +++ b/classes/Database/DatabaseController.php @@ -171,6 +171,9 @@ private function ensureConnector(): void case 'mysql': case 'mariadb': $type = 'mariadb'; + break; + case 'sqlite': + $type = 'sqlite'; break; default: throw new RuntimeException('Unknown database type: ' . $this->conf_data['type']); diff --git a/classes/Database/Sqlite/Connector.php b/classes/Database/Sqlite/Connector.php new file mode 100644 index 0000000..8d0d69e --- /dev/null +++ b/classes/Database/Sqlite/Connector.php @@ -0,0 +1,510 @@ +. + * + * ========================= + * + * This file contains the DatabaseConnector class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\ErrorHandler; +use Liuch\DmarcSrg\Database\DatabaseConnector; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\RuntimeException; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseExceptionFactory; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +class Connector extends DatabaseConnector +{ + protected $dbh = null; + + /** + * Returns an instance of PDO class + * + * @return \PDO + */ + public function dbh(): object + { + $this->ensureConnection(); + return $this->dbh; + } + + /** + * Returns the name of the database + * + * @return string + */ + public function dbName(): string + { + return $this->name; + } + + /** + * Returns information about the database as an array. + * + * @return array May contain the following fields: + * tables - an array of tables with their properties; + * correct - true if the database is correct; + * version - the current version of the database structure; + * message - a state message; + * error_code - an error code; + */ + public function state(): array + { + $this->ensureConnection(); + + $res = []; + + try { + $tables = []; + $st = $this->dbh->query( + 'SELECT name FROM sqlite_schema WHERE type=\'table\'' + ); + while ($row = $st->fetch(\PDO::FETCH_ASSOC)) { + $tname = $row['name']; + $rcnt = $this->dbh->query('SELECT COUNT(*) FROM ' . $tname . '')->fetch(\PDO::FETCH_NUM)[0]; + $tables[$tname] = [ + 'engine' => 'sqlite', + 'rows' => intval($rcnt), + 'data_length' => null, + 'index_length' => null, + 'create_time' => null, + 'update_time' => null + ]; + } + foreach (array_keys(self::$schema) as $table) { + if (!isset($tables[$table])) { + $tables[$table] = false; + } + } + $exist_cnt = 0; + $absent_cnt = 0; + $tables_res = []; + foreach ($tables as $tname => $tval) { + $t = null; + if ($tval) { + $t = $tval; + $t['exists'] = true; + if (isset(self::$schema[$tname])) { + ++$exist_cnt; + $t['message'] = 'Ok'; + } else { + $t['message'] = 'Unknown table'; + } + } else { + ++$absent_cnt; + $t = [ + 'error_code' => 1, + 'message' => 'Not exist' + ]; + } + $t['name'] = $tname; + $tables_res[] = $t; + } + $res['tables'] = $tables_res; + if ($absent_cnt === 0) { + $res['correct'] = true; + $res['message'] = 'Ok'; + try { + $res['version'] = $this->getMapper('setting')->value('version'); + } catch (DatabaseNotFoundException $e) { + } + } else { + $res['error_code'] = -1; + if ($exist_cnt == 0) { + $res['message'] = 'The database schema is not initiated'; + } else { + $res['message'] = 'Incomplete set of the tables'; + } + } + } catch (\PDOException $e) { + $res = array_replace($res, ErrorHandler::exceptionResult( + new DatabaseFatalException('Failed to get the database information', -1, $e) + )); + } catch (RuntimeException $e) { + $res = array_replace($res, ErrorHandler::exceptionResult($e)); + } + return $res; + } + + /** + * Inites the database. + * + * This method creates needed tables and indexes in the database. + * The method will fail if the database already have tables with the table prefix. + * + * @param $version The current version of the database schema + * + * @return void + */ + public function initDb(string $version): void + { + $this->ensureConnection(); + try { + $st = $this->dbh->query($this->sqlShowTablesQuery()); + try { + if ($st->fetch()) { + if (empty($this->tablePrefix())) { + throw new SoftException('The database is not empty', -4); + } else { + throw new SoftException('Database tables already exist with the given prefix', -4); + } + } + foreach (self::$schema as $t_name => &$t_schema) { + $this->createDbTable($this->tablePrefix($t_name), $t_schema); + } + unset($t_schema); + } finally { + $st->closeCursor(); + } + $st = $this->dbh->prepare( + 'INSERT INTO ' . $this->tablePrefix('system') . ' (key, value) VALUES ("version", ?)' + ); + $st->bindValue(1, $version, \PDO::PARAM_STR); + $st->execute(); + $st->closeCursor(); + } catch (\PDOException $e) { + die(Throw $e); + throw new DatabaseFatalException('Failed to create required tables in the database', -1, $e); + } + } + + /** + * Cleans up the database + * + * Drops tables with the table prefix in the database or all tables in the database + * if no table prefix is set. + * + * @return void + */ + public function cleanDb(): void + { + $this->ensureConnection(); + try { + $db = $this->dbh; + $db->query('PRAGMA FOREIGN_KEYS = OFF'); + $st = $db->query($this->sqlShowTablesQuery()); + while ($table = $st->fetchColumn(0)) { + $db->query('DROP TABLE ' . $table . ''); + } + $st->closeCursor(); + $db->query('PRAGMA FOREIGN_KEYS = ON'); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to drop the database tables', -1, $e); + } + } + + /** + * Sets the database connection if it hasn't connected yet. + * + * @return void + */ + private function ensureConnection(): void + { + if (!$this->dbh) { + try { + $this->dbh = new \PDO( + "sqlite:$this->name", + $this->user, + $this->password, + [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION ] + ); + } catch (\PDOException $e) { + throw DatabaseExceptionFactory::fromException($e); + } + } + } + + /** + * Return SHOW TABLES SQL query string for tables with the table prefix + * + * @return string + */ + private function sqlShowTablesQuery(): string + { + $res = 'SELECT name FROM sqlite_schema WHERE type=\'table\' ORDER BY name;'; + return $res; + } + + /** + * Creates a table in the database. + * + * @param string $name Table name + * @param array $definitions Table structure + * + * @return void + */ + private function createDbTable(string $name, array $definitions): void + { + $query = 'CREATE TABLE ' . $name . ' ('; + $col_num = 0; + foreach ($definitions['columns'] as $column) { + if ($col_num > 0) { + $query .= ', '; + } + $query .= '' . $column['name'] . ' ' . $column['definition']; + $col_num += 1; + } + $query .= '); ' . $definitions['table_options']; + $this->dbh->query($query); + foreach($definitions['sub_queries'] as $sub_query) { + $this->dbh->query($sub_query); + } + } + + private static $schema = [ + 'system' => [ + 'columns' => [ + [ + 'name' => 'key', + 'definition' => 'TEXT NOT NULL PRIMARY KEY' + ], + [ + 'name' => 'value', + 'definition' => 'TEXT DEFAULT NULL' + ] + ], + 'additional' => '', + 'table_options' => '', + 'sub_queries' => [] + ], + 'domains' => [ + 'columns' => [ + [ + 'name' => 'id', + 'definition' => 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT' + ], + [ + 'name' => 'fqdn', + 'definition' => 'TEXT NOT NULL UNIQUE' + ], + [ + 'name' => 'active', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'description', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'created_time', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'updated_time', + 'definition' => 'TEXT NOT NULL' + ] + ], + 'additional' => '', + 'table_options' => '', + 'sub_queries' => [] + ], + 'reports' => [ + 'columns' => [ + [ + 'name' => 'id', + 'definition' => 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT' + ], + [ + 'name' => 'domain_id', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'begin_time', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'end_time', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'loaded_time', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'org', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'external_id', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'email', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'extra_contact_info', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'error_string', + 'definition' => 'text NULL' + ], + [ + 'name' => 'policy_adkim', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'policy_aspf', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'policy_p', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'policy_sp', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'policy_pct', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'policy_fo', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'seen', + 'definition' => 'INTEGER NOT NULL' + ] + ], + 'additional' => '', + 'table_options' => '', + 'sub_queries' => [ + 'CREATE UNIQUE INDEX reports_domain_id_external_id ON reports(domain_id, external_id)', + 'CREATE INDEX reports_begin_time ON reports(begin_time)', + 'CREATE INDEX reports_end_time ON reports(end_time)' + ] + ], + 'rptrecords' => [ + 'columns' => [ + [ + 'name' => 'id', + 'definition' => 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT' + ], + [ + 'name' => 'report_id', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'ip', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'rcount', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'disposition', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'reason', + 'definition' => 'text NULL' + ], + [ + 'name' => 'dkim_auth', + 'definition' => 'text NULL' + ], + [ + 'name' => 'spf_auth', + 'definition' => 'text NULL' + ], + [ + 'name' => 'dkim_align', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'spf_align', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'envelope_to', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'envelope_from', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'header_from', + 'definition' => 'TEXT NULL' + ] + ], + 'additional' => '', + 'table_options' => '', + 'sub_queries' => [ + 'CREATE INDEX rptrecords_report_id ON rptrecords(report_id)', + 'CREATE INDEX rptrecords_ip ON rptrecords(ip)' + ] + ], + 'reportlog' => [ + 'columns' => [ + [ + 'name' => 'id', + 'definition' => 'INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT' + ], + [ + 'name' => 'domain', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'external_id', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'event_time', + 'definition' => 'TEXT NOT NULL' + ], + [ + 'name' => 'filename', + 'definition' => 'TEXT NULL' + ], + [ + 'name' => 'source', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'success', + 'definition' => 'INTEGER NOT NULL' + ], + [ + 'name' => 'message', + 'definition' => 'text NULL' + ] + ], + 'additional' => '', + 'table_options' => '', + 'sub_queries' => [ + 'CREATE INDEX reportlog_event_time ON reportlog(event_time)' + ] + ] + ]; +} diff --git a/classes/Database/Sqlite/DomainMapper.php b/classes/Database/Sqlite/DomainMapper.php new file mode 100644 index 0000000..b6fef8a --- /dev/null +++ b/classes/Database/Sqlite/DomainMapper.php @@ -0,0 +1,332 @@ +. + * + * ========================= + * + * This file contains the DomainMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\DateTime; +use Liuch\DmarcSrg\Database\DomainMapperInterface; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +/** + * DomainMapper class implementation for MariaDB + */ +class DomainMapper implements DomainMapperInterface +{ + private $connector = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Return true if the domain exists or false otherwise. + * + * @param array $data Array with domain data to search + * + * @return bool + */ + public function exists(array &$data): bool + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT id FROM ' . $this->connector->tablePrefix('domains') . + ' WHERE ' . $this->sqlCondition($data) + ); + $this->sqlBindValue($st, 1, $data); + $st->execute(); + $res = $st->fetch(\PDO::FETCH_NUM); + $st->closeCursor(); + if (!$res) { + return false; + } + $data['id'] = intval($res[0]); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get domain ID', -1, $e); + } + return true; + } + + /** + * Fetch the domain data from the database by its id or name + * + * @param array $data Domain data to update + * + * @return void + */ + public function fetch(array &$data): void + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT id, fqdn, active, description, created_time, updated_time FROM ' + . $this->connector->tablePrefix('domains') . ' WHERE ' . $this->sqlCondition($data) + ); + $this->sqlBindValue($st, 1, $data); + $st->execute(); + $res = $st->fetch(\PDO::FETCH_NUM); + $st->closeCursor(); + if (!$res) { + throw new DatabaseNotFoundException('Domain not found'); + } + $data['id'] = intval($res[0]); + $data['fqdn'] = $res[1]; + $data['active'] = boolval($res[2]); + $data['description'] = $res[3]; + $data['created_time'] = new DateTime($res[4]); + $data['updated_time'] = new DateTime($res[5]); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to fetch the domain data', -1, $e); + } + } + + /** + * Saves domain data to the database (updates or inserts an record) + * + * @param array $data Domain data + * + * @return void + */ + public function save(array &$data): void + { + $db = $this->connector->dbh(); + $data['updated_time'] = new DateTime(); + if ($this->exists($data)) { + try { + $st = $db->prepare( + 'UPDATE ' . $this->connector->tablePrefix('domains') + . ' SET active = ?, description = ?, updated_time = ? WHERE id = ?' + ); + $st->bindValue(1, $data['active'], \PDO::PARAM_BOOL); + $st->bindValue(2, $data['description'], \PDO::PARAM_STR); + $st->bindValue(3, $data['updated_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(4, $data['id'], \PDO::PARAM_INT); + $st->execute(); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DababaseException('Failed to update the domain data', -1, $e); + } + } else { + try { + $active = $data['active'] ?? false; + $data['created_time'] = $data['updated_time']; + if (is_null($data['description'])) { + $sql1 = ''; + $sql2 = ''; + } else { + $sql1 = ', description'; + $sql2 = ', ?'; + } + $st = $db->prepare( + 'INSERT INTO ' . $this->connector->tablePrefix('domains') + . ' (fqdn, active' . $sql1 . ', created_time, updated_time)' + . ' VALUES (?, ?' . $sql2 . ', ?, ?)' + ); + $idx = 0; + $st->bindValue(++$idx, $data['fqdn'], \PDO::PARAM_STR); + $st->bindValue(++$idx, $active, \PDO::PARAM_BOOL); + if (!is_null($data['description'])) { + $st->bindValue(++$idx, $data['description'], \PDO::PARAM_STR); + } + $st->bindValue(++$idx, $data['created_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(++$idx, $data['updated_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->execute(); + $st->closeCursor(); + $data['id'] = intval($db->lastInsertId()); + $data['active'] = $active; + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to insert the domain data', -1, $e); + } + } + } + + /** + * Deletes the domain from the database + * + * Deletes the domain if there are no reports for this domain in the database. + * + * @param array $data Domain data + * + * @return void + */ + public function delete(array &$data): void + { + $db = $this->connector->dbh(); + $db->beginTransaction(); + try { + $filter = [ 'domain' => $data['id'] ]; + $limit = [ 'offset' => 0, 'count' => 0 ]; + $r_count = $this->connector->getMapper('report')->count($filter, $limit); + if ($r_count > 0) { + switch ($r_count) { + case 1: + $s1 = 'is'; + $s2 = ''; + break; + default: + $s1 = 'are'; + $s2 = 's'; + break; + } + throw new SoftException( + "Failed to delete: there {$s1} {$r_count} incoming report{$s2} for this domain" + ); + } + $st = $db->prepare('DELETE FROM ' . $this->connector->tablePrefix('domains') . ' WHERE id = ?'); + $st->bindValue(1, $data['id'], \PDO::PARAM_INT); + $st->execute(); + $st->closeCursor(); + $db->commit(); + } catch (\PDOException $e) { + $db->rollBack(); + throw new DatabaseFatalException('Failed to delete the domain', -1, $e); + } catch (\Exception $e) { + $db->rollBack(); + throw $e; + } + } + + /** + * Returns a list of domains data from the database + * + * @return array + */ + public function list(): array + { + $list = []; + try { + $st = $this->connector->dbh()->query( + 'SELECT id, fqdn, active, description, created_time, updated_time FROM ' + . $this->connector->tablePrefix('domains') . '' + ); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $list[] = [ + 'id' => intval($row[0]), + 'fqdn' => $row[1], + 'active' => boolval($row[2]), + 'description' => $row[3], + 'created_time' => new DateTime($row[4]), + 'updated_time' => new DateTime($row[5]) + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the domain list', -1, $e); + } + return $list; + } + + /** + * Returns an ordered array with domain names from the database + * + * @return array + */ + public function names(): array + { + $res = []; + try { + $st = $this->connector->dbh()->query( + 'SELECT fqdn FROM ' . $this->connector->tablePrefix('domains') . ' ORDER BY fqdn', + \PDO::FETCH_NUM + ); + while ($name = $st->fetchColumn(0)) { + $res[] = $name; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get a list of domain names', -1, $e); + } + return $res; + } + + /** + * Returns the total number of domains in the database + * + * @param int $max The maximum number of records to count. 0 means no limitation. + * + * @return int The total number of domains + */ + public function count(int $max = 0): int + { + $number = 0; + try { + $query_str = 'SELECT COUNT(*) FROM ' . $this->connector->tablePrefix('domains') . ''; + if ($max > 0) { + $query_str .= " LIMIT {$max}"; + } + $st = $this->connector->dbh()->query($query_str, \PDO::FETCH_NUM); + $number = intval($st->fetchColumn(0)); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the number of domains', -1, $e); + } + return $number; + } + + /** + * Returns a condition string for a WHERE statement based on existing domain data + * + * @param array $data Domain data + * + * @return string Condition string + */ + private function sqlCondition(array &$data): string + { + if (isset($data['id'])) { + return 'id = ?'; + } + return 'fqdn = ?'; + } + + /** + * Binds values for SQL queries based on existing domain data + * + * @param PDOStatement $st PDO Statement to bind to + * @param ind $pos Start position for binding + * @param array $data Domain data + * + * @return void + */ + private function sqlBindValue($st, int $pos, array &$data): void + { + if (isset($data['id'])) { + $st->bindValue($pos, $data['id'], \PDO::PARAM_INT); + } else { + $st->bindValue($pos, $data['fqdn'], \PDO::PARAM_STR); + } + } +} diff --git a/classes/Database/Sqlite/ReportLogMapper.php b/classes/Database/Sqlite/ReportLogMapper.php new file mode 100644 index 0000000..6ac7863 --- /dev/null +++ b/classes/Database/Sqlite/ReportLogMapper.php @@ -0,0 +1,311 @@ +. + * + * ========================= + * + * This file contains the ReportLogMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\DateTime; +use Liuch\DmarcSrg\Database\ReportLogMapperInterface; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +/** + * ReportLogMapper class implementation for MariaDB + */ +class ReportLogMapper implements ReportLogMapperInterface +{ + private $connector = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Fetches data of report log item from the database by id + * + * @param Report log data + * + * @return void + */ + public function fetch(array &$data): void + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT domain, external_id, event_time, filename, source, success, message FROM ' + . $this->connector->tablePrefix('reportlog') . ' WHERE id = ?' + ); + $st->bindValue(1, $data['id'], \PDO::PARAM_INT); + $st->execute(); + if (!($row = $st->fetch(\PDO::FETCH_NUM))) { + throw new DatabaseNotFoundException(); + } + $data['domain'] = $row[0]; + $data['external_id'] = $row[1]; + $data['event_time'] = new DateTime($row[2]); + $data['filename'] = $row[3]; + $data['source'] = intval($row[4]); + $data['success'] = boolval($row[5]); + $data['message'] = $row[6]; + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the log item', -1, $e); + } + } + + /** + * Saves data of report log item to the database + * + * @return void + */ + public function save(array &$data): void + { + $db = $this->connector->dbh(); + try { + $id = $data['id']; + if (is_null($id)) { + $st = $db->prepare( + 'INSERT INTO ' . $this->connector->tablePrefix('reportlog') + . ' (domain, external_id, event_time, filename, source, success, message)' + . ' VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + } else { + $st = $db->prepare( + 'UPDATE ' . $this->connector->tablePrefix('reportlog') + . ' SET domain = ?, external_id = ?, event_time = ?, filename = ?,' + . ' source = ?, success = ?, message = ? WHERE id = ?' + ); + $st->bindValue(8, $id, \PDO::PARAM_INT); + } + $ts = $data['event_time'] ?? (new DateTime()); + $st->bindValue(1, $data['domain'], \PDO::PARAM_STR); + $st->bindValue(2, $data['external_id'], \PDO::PARAM_STR); + $st->bindValue(3, $ts->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(4, $data['filename'], \PDO::PARAM_STR); + $st->bindValue(5, $data['source'], \PDO::PARAM_INT); + $st->bindValue(6, $data['success'], \PDO::PARAM_BOOL); + $st->bindValue(7, $data['message'], \PDO::PARAM_STR); + $st->execute(); + if (is_null($id)) { + $data['id'] = intval($db->lastInsertId()); + } + $st->closeCursor(); + $data['event_time'] = $ts; + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to save a report log item'); + } + } + + /** + * Returns a list of report log items with given criteria + * + * @param array $filter Key-value array: + * 'from_time' => DateTime + * 'till_time' => DateTime + * @param array $order Key-value array with order options: + * 'direction' => string, 'ascent' or 'descent' + * @param array $limit Key-value array: + * 'offset' => int + * 'count' => int + * + * @return array + */ + public function list(array &$filter, array &$order, array &$limit): array + { + $list = []; + try { + $st = $this->connector->dbh()->prepare( + 'SELECT id, domain, event_time, source, success, message FROM ' + . $this->connector->tablePrefix('reportlog') . '' + . $this->sqlCondition($filter) + . $this->sqlOrder($order) + . $this->sqlLimit($limit) + ); + $this->sqlBindValues($st, $filter, $limit); + $st->execute(); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $list[] = [ + 'id' => intval($row[0]), + 'domain' => $row[1], + 'event_time' => new DateTime($row[2]), + 'source' => intval($row[3]), + 'success' => boolval($row[4]), + 'message' => $row[5] + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the logs', -1, $e); + } + return $list; + } + + /** + * Returns the number of report log items matching the specified filter and limits + * + * @param array $filter Key-value array with filtering parameters + * @param array $limit Key-value array with limits + * + * @return int + */ + public function count(array &$filter, array &$limit): int + { + $cnt = 0; + try { + $st = $this->connector->dbh()->prepare( + 'SELECT COUNT(*) FROM ' . $this->connector->tablePrefix('reportlog') . '' + . $this->sqlCondition($filter) + . $this->sqlLimit($limit) + ); + $this->sqlBindValues($st, $filter, $limit); + $st->execute(); + $cnt = intval($st->fetch(\PDO::FETCH_NUM)[0]); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the log data', -1, $e); + } + return $cnt; + } + + /** + * Deletes report log items from the database + * + * @param array $filter Key-value array with filtering parameters + * @param array $order Key-value array with order options: + * 'direction' => string, 'ascent' or 'descent' + * @param array $limit Key-value array with limits + * + * @return void + */ + public function delete(array &$filter, array &$order, array &$limit): void + { + try { + $st = $this->connector->dbh()->prepare( + 'DELETE FROM ' . $this->connector->tablePrefix('reportlog') . '' + . $this->sqlCondition($filter) + . $this->sqlOrder($order) + . $this->sqlLimit($limit) + ); + $this->sqlBindValues($st, $filter, $limit); + $st->execute(); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to remove the log data', -1, $e); + } + } + + /** + * Returns a string with an SQL condition 'WHERE ...' + * + * @param array $filter Key-value with filtering paremeters + * + * @return string + */ + private function sqlCondition(array &$filter): string + { + $res = ''; + if (!is_null($filter['from_time']) || !is_null($filter['till_time'])) { + $res = ' WHERE'; + $till_time = $filter['till_time']; + if (!is_null($filter['from_time'])) { + $res .= ' event_time >= ?'; + if (!is_null($till_time)) { + $res .= ' AND'; + } + } + if (!is_null($till_time)) { + $res .= ' event_time < ?'; + } + } + return $res; + } + + /** + * Returns 'ORDER BY ...' part of the SQL query + * + * @param array $order Key-value array with ordering options + * + * @return string + */ + private function sqlOrder(array &$order): string + { + return ' ORDER BY event_time ' . ($order['direction'] === 'descent' ? 'DESC' : 'ASC'); + } + + /** + * Returns 'LIMIT ...' part of the SQL string + * + * @param array $limit Key-value array with keys 'offset' and 'count' + * + * @return string + */ + private function sqlLimit(array &$limit): string + { + $res = ''; + if ($limit['count'] > 0) { + $res = ' LIMIT ?'; + if ($limit['offset'] > 0) { + $res .= ', ?'; + } + } + return $res; + } + + /** + * Binds the values of the filter and the limit to SQL query + * + * @param PDOStatement $st Prepared SOL statement to bind to + * @param array $filter Key-value array with filter data + * @param array $limit Key-value array with limit data + * + * @return void + */ + private function sqlBindValues($st, array &$filter, array &$limit): void + { + $pos = 0; + if (!is_null($filter['from_time'])) { + $st->bindValue(++$pos, $filter['from_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + } + if (!is_null($filter['till_time'])) { + $st->bindValue(++$pos, $filter['till_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + } + if ($limit['count'] > 0) { + if ($limit['offset'] > 0) { + $st->bindValue(++$pos, $limit['offset'], \PDO::PARAM_INT); + } + $st->bindValue(++$pos, $limit['count'], \PDO::PARAM_INT); + } + } +} diff --git a/classes/Database/Sqlite/ReportMapper.php b/classes/Database/Sqlite/ReportMapper.php new file mode 100644 index 0000000..0d77a7b --- /dev/null +++ b/classes/Database/Sqlite/ReportMapper.php @@ -0,0 +1,752 @@ +. + * + * ========================= + * + * This file contains the ReportMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\Core; +use Liuch\DmarcSrg\Common; +use Liuch\DmarcSrg\DateTime; +use Liuch\DmarcSrg\Settings\SettingsList; +use Liuch\DmarcSrg\Database\ReportMapperInterface; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\LogicException; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +/** + * ReportMapper class implementation for MariaDB + */ +class ReportMapper implements ReportMapperInterface +{ + private $connector = null; + + private static $allowed_domains = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Fetches report data from the database and stores it in the passed array + * + * @param array $data Array with report data. To identify the report, + * the array must contain at least two fields: + * report_id - External report id from the xml file + * domain - Fully Qualified Domain Name without a trailing dot + * + * @return void + */ + public function fetch(array &$data): void + { + $db = $this->connector->dbh(); + try { + $st = $db->prepare( + 'SELECT rp.id, begin_time, end_time, loaded_time, org, email, extra_contact_info,' + . ' error_string, policy_adkim, policy_aspf, policy_p, policy_sp, policy_pct, policy_fo' + . ' FROM ' . $this->connector->tablePrefix('reports') . ' AS rp' + . ' INNER JOIN ' . $this->connector->tablePrefix('domains') + . ' AS dom ON dom.id = rp.domain_id' + . ' WHERE fqdn = ? AND external_id = ?' + ); + $st->bindValue(1, $data['domain'], \PDO::PARAM_STR); + $st->bindValue(2, $data['report_id'], \PDO::PARAM_STR); + $st->execute(); + if (!($res = $st->fetch(\PDO::FETCH_NUM))) { + throw new DatabaseNotFoundException('The report is not found'); + } + $id = intval($res[0]); + $data['date'] = [ + 'begin' => new DateTime($res[1]), + 'end' => new DateTime($res[2]) + ]; + $data['loaded_time'] = new DateTime($res[3]); + $data['org_name'] = $res[4]; + $data['email'] = $res[5]; + $data['extra_contact_info'] = $res[6]; + $data['error_string'] = json_decode($res[7] ?? '', true); + $data['policy'] = [ + 'adkim' => $res[8], + 'aspf' => $res[9], + 'p' => $res[10], + 'sp' => $res[11], + 'pct' => $res[12], + 'fo' => $res[13] + ]; + + $order_str = $this->sqlOrderRecords(); + $st = $db->prepare( + 'SELECT report_id, ip, rcount, disposition, reason, dkim_auth , spf_auth, dkim_align,' + . ' spf_align, envelope_to, envelope_from, header_from' + . ' FROM ' . $this->connector->tablePrefix('rptrecords') . ' WHERE report_id = ?' . $order_str + ); + $st->bindValue(1, $id, \PDO::PARAM_INT); + $st->execute(); + $data['records'] = []; + while ($res = $st->fetch(\PDO::FETCH_NUM)) { + $data['records'][] = [ + 'ip' => inet_ntop($res[1]), + 'count' => intval($res[2]), + 'disposition' => Common::$disposition[$res[3]], + 'reason' => json_decode($res[4] ?? '', true), + 'dkim_auth' => json_decode($res[5] ?? '', true), + 'spf_auth' => json_decode($res[6] ?? '', true), + 'dkim_align' => Common::$align_res[$res[7]], + 'spf_align' => Common::$align_res[$res[8]], + 'envelope_to' => $res[9], + 'envelope_from' => $res[10], + 'header_from' => $res[11] + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the report from DB', -1, $e); + } + } + + /** + * Inserts report data into the database. + * + * @param array $data Report data + * + * @return void + */ + public function save(array &$data): void + { + $db = $this->connector->dbh(); + $db->beginTransaction(); + try { + $domain_data = [ 'fqdn' => strtolower($data['domain']) ]; + $domain_mapper = $this->connector->getMapper('domain'); + try { + $domain_mapper->fetch($domain_data); + if (!$domain_data['active']) { + throw new SoftException('Failed to add an incoming report: the domain is inactive'); + } + } catch (DatabaseNotFoundException $e) { + // The domain is not found. Let's try to add it automatically. + $this->insertDomain($domain_data, $domain_mapper); + } + + $ct = new DateTime(); + $st = $db->prepare( + 'INSERT INTO ' . $this->connector->tablePrefix('reports') + . ' (domain_id, begin_time, end_time, loaded_time, org, external_id, email,' + . ' extra_contact_info, error_string, policy_adkim, policy_aspf, policy_p,' + . ' policy_sp, policy_pct, policy_fo, seen)' + . ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' + ); + $st->bindValue(1, $domain_data['id'], \PDO::PARAM_INT); + $st->bindValue(2, $data['begin_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(3, $data['end_time']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(4, $ct->format('Y-m-d H:i:s'), \PDO::PARAM_STR); + $st->bindValue(5, $data['org'], \PDO::PARAM_STR); + $st->bindValue(6, $data['external_id'], \PDO::PARAM_STR); + $st->bindValue(7, $data['email'], \PDO::PARAM_STR); + $st->bindValue(8, $data['extra_contact_info'], \PDO::PARAM_STR); + self::sqlBindJson($st, 9, $data['error_string']); + $st->bindValue(10, $data['policy_adkim'], \PDO::PARAM_STR); + $st->bindValue(11, $data['policy_aspf'], \PDO::PARAM_STR); + $st->bindValue(12, $data['policy_p'], \PDO::PARAM_STR); + $st->bindValue(13, $data['policy_sp'], \PDO::PARAM_STR); + $st->bindValue(14, $data['policy_pct'], \PDO::PARAM_STR); + $st->bindValue(15, $data['policy_fo'], \PDO::PARAM_STR); + $st->execute(); + $new_id = intval($db->lastInsertId()); + $st->closeCursor(); + + $st = $db->prepare( + 'INSERT INTO ' . $this->connector->tablePrefix('rptrecords') + . ' (report_id, ip, rcount, disposition, reason, dkim_auth, spf_auth, dkim_align,' + . ' spf_align, envelope_to, envelope_from, header_from)' + . ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + foreach ($data['records'] as &$rec_data) { + $st->bindValue(1, $new_id, \PDO::PARAM_INT); + $st->bindValue(2, inet_pton($rec_data['ip']), \PDO::PARAM_STR); + $st->bindValue(3, $rec_data['rcount'], \PDO::PARAM_INT); + $st->bindValue(4, array_search($rec_data['disposition'], Common::$disposition), \PDO::PARAM_INT); + self::sqlBindJson($st, 5, $rec_data['reason']); + self::sqlBindJson($st, 6, $rec_data['dkim_auth']); + self::sqlBindJson($st, 7, $rec_data['spf_auth']); + $st->bindValue(8, array_search($rec_data['dkim_align'], Common::$align_res), \PDO::PARAM_INT); + $st->bindValue(9, array_search($rec_data['spf_align'], Common::$align_res), \PDO::PARAM_INT); + $st->bindValue(10, $rec_data['envelope_to'], \PDO::PARAM_STR); + $st->bindValue(11, $rec_data['envelope_from'], \PDO::PARAM_STR); + $st->bindValue(12, $rec_data['header_from'], \PDO::PARAM_STR); + $st->execute(); + } + unset($rec_data); + $db->commit(); + $data['loaded_time'] = $ct; + } catch (\PDOException $e) { + $db->rollBack(); + if ($e->getCode() == '23000') { + throw new SoftException('This report has already been loaded'); + } + throw new DatabaseFatalException('Failed to insert the report', -1, $e); + } catch (\Exception $e) { + $db->rollBack(); + throw $e; + } + } + + /** + * Sets report record property in database. + * + * It has nothing to do with the fields of the report itself. + * + * @param array $data Report data + * @param string $name Property name. Currently only seen is supported. + * @param variant $value Property value + * + * @return void + */ + public function setProperty(array &$data, string $name, $value): void + { + if ($name !== 'seen' && gettype($value) !== 'boolean') { + throw new LogicException('Incorrect parameters'); + } + + try { + $st = $this->connector->dbh()->prepare( + 'UPDATE ' . $this->connector->tablePrefix('reports') . ' AS rp' + . ' INNER JOIN ' . $this->connector->tablePrefix('domains') . ' AS dom' + . ' ON rp.domain_id = dom.id SET seen = ? WHERE fqdn = ? AND external_id = ?' + ); + $st->bindValue(1, $value, \PDO::PARAM_BOOL); + $st->bindValue(2, $data['domain'], \PDO::PARAM_STR); + $st->bindValue(3, $data['report_id'], \PDO::PARAM_STR); + $st->execute(); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to update the DB record', -1, $e); + } + } + + /** + * Returns a list of reports with specified parameters + * + * This method returns a list of reports that depends on the $filter, $order and $limit. + * + * @param array $filter Key-value array with filtering parameters + * @param array $order Key-value array: + * 'field' => string, 'begin_time' + * 'direction' => string, 'ascent' or 'descent' + * @param array $limit Key-value array with two keys: offset and count + * + * @return array + */ + public function list(array &$filter, array &$order, array &$limit): array + { + $db = $this->connector->dbh(); + $list = []; + $f_data = $this->prepareFilterData($filter); + $order_str = $this->sqlOrderList($order); + $cond_str0 = $this->sqlConditionList($f_data, ' AND ', 0); + $cond_str1 = $this->sqlConditionList($f_data, ' HAVING ', 1); + $limit_str = $this->sqlLimit($limit); + try { + + $st = $db->prepare( + 'SELECT org, begin_time, end_time, fqdn, external_id, seen, SUM(rcount) AS rcount,' + . ' MIN(dkim_align) AS dkim_align, MIN(spf_align) AS spf_align,' + . ' MIN(disposition) AS disposition FROM ' . $this->connector->tablePrefix('reports') + . ' AS rp LEFT JOIN ' . $this->connector->tablePrefix('rptrecords') + . ' AS rr ON rp.id = rr.report_id' + . ' INNER JOIN ' . $this->connector->tablePrefix('domains') + . ' AS d ON d.id = rp.domain_id' + . ' GROUP BY rp.id' + . $cond_str1 . $order_str . $limit_str + ); + $this->sqlBindValues($st, $f_data, $limit); + $st->execute(); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $list[] = [ + 'org_name' => $row[0], + 'date' => [ + 'begin' => new DateTime($row[1]), + 'end' => new DateTime($row[2]) + ], + 'domain' => $row[3], + 'report_id' => $row[4], + 'seen' => (bool) $row[5], + 'messages' => $row[6], + 'dkim_align' => Common::$align_res[$row[7]], + 'spf_align' => Common::$align_res[$row[8]], + 'disposition' => Common::$disposition[$row[9]] + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + var_dump($e); + throw new DatabaseFatalException('Failed to get the report list', -1, $e); + } + return $list; + } + + /** + * Returns the number of reports matching the specified filter and limits + * + * @param array $filter Key-value array with filtering parameters + * @param array $limit Key-value array with two keys: offset and count + * + * @return int + */ + public function count(array &$filter, array &$limit): int + { + $cnt = 0; + $f_data = $this->prepareFilterData($filter); + try { + $st = $this->connector->dbh()->prepare( + 'SELECT COUNT(*) FROM ' . $this->connector->tablePrefix('reports') . ' AS rp' + . $this->sqlConditionList($f_data, ' WHERE ', 0) + ); + $l_empty = [ 'offset' => 0, 'count' => 0 ]; + $this->sqlBindValues($st, $f_data, $l_empty); + $st->execute(); + $cnt = intval($st->fetch(\PDO::FETCH_NUM)[0]); + $st->closeCursor(); + + $offset = $limit['offset']; + if ($offset > 0) { + $cnt -= $offset; + if ($cnt < 0) { + $cnt = 0; + } + } + $max = $limit['count']; + if ($max > 0 && $max < $cnt) { + $cnt = $max; + } + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get the number of reports', -1, $e); + } + return $cnt; + } + + /** + * Deletes reports from the database + * + * It deletes repors form the database. The filter options dkim and spf do not affect this. + * + * @param array $filter Key-value array with filtering parameters + * @param array $order Key-value array: + * 'field' => string, 'begin_time' + * 'direction' => string, 'ascent' or 'descent' + * @param array $limit Key-value array with two keys: offset and count + * + * @return void + */ + public function delete(array &$filter, array &$order, array &$limit): void + { + $f_data = $this->prepareFilterData($filter); + $cond_str = $this->sqlConditionList($f_data, ' WHERE ', 0); + $order_str = $this->sqlOrderList($order); + $limit_str = $this->sqlLimit($limit); + $db = $this->connector->dbh(); + $db->beginTransaction(); + try { + $st = $db->prepare( + 'DELETE rr FROM ' . $this->connector->tablePrefix('rptrecords') + . ' AS rr INNER JOIN (SELECT id FROM ' . $this->connector->tablePrefix('reports') . '' + . $cond_str . $order_str . $limit_str . ') AS rp ON rp.id = rr.report_id' + ); + $this->sqlBindValues($st, $f_data, $limit); + $st->execute(); + $st->closeCursor(); + + $st = $db->prepare( + 'DELETE FROM ' . $this->connector->tablePrefix('reports') . "{$cond_str}{$order_str}{$limit_str}" + ); + $this->sqlBindValues($st, $f_data, $limit); + $st->execute(); + $st->closeCursor(); + + $db->commit(); + } catch (\PDOException $e) { + $db->rollBack(); + throw new DatabaseFatalException('Failed to delete reports', -1, $e); + } catch (\Exception $e) { + $db->rollBack(); + throw $e; + } + } + + /** + * Returns a list of months with years of the form: 'yyyy-mm' for which there is at least one report + * + * @return array + */ + public function months(): array + { + $res = []; + $rep_tn = $this->connector->tablePrefix('reports'); + try { + $st = $this->connector->dbh()->query( + 'SELECT DISTINCT strftime("%Y-%m", date) AS month FROM' + . ' (SELECT DISTINCT begin_time AS date FROM ' . $rep_tn + . ' UNION SELECT DISTINCT end_time AS date FROM ' . $rep_tn + . ') AS r ORDER BY month DESC' + ); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $res[] = $row[0]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get a list of months', -1, $e); + } + return $res; + } + + /** + * Returns a list of reporting organizations from which there is at least one report + * + * @return array + */ + public function organizations(): array + { + $res = []; + $rep_tn = $this->connector->tablePrefix('reports'); + try { + $st = $this->connector->dbh()->query( + 'SELECT DISTINCT org FROM ' . $rep_tn . ' ORDER BY org' + ); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $res[] = $row[0]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get a list of organizations', -1, $e); + } + return $res; + } + + /** + * Returns ORDER BY ... part of the SQL query for report records + * + * @return string + */ + private function sqlOrderRecords(): string + { + $o_set = explode(',', SettingsList::getSettingByName('report-view.sort-records-by')->value()); + switch ($o_set[0]) { + case 'ip': + $fname = 'ip'; + break; + case 'message-count': + default: + $fname = 'rcount'; + break; + } + $dir = $o_set[1] === 'descent' ? 'DESC' : 'ASC'; + + return " ORDER BY {$fname} {$dir}"; + } + + /** + * Checks if the domain exists and adds it to the database if necessary + * + * It automatically adds the domain if there are no domains in the database + * or if the domain match the allowed_domains reqular expression in the configuration file. + * Otherwise, throws a SoftException. + * + * @param array $data Domain data + * @param object $mapper Domain mapper + * + * @return void + */ + private function insertDomain(array &$data, $mapper): void + { + $mapper = $this->connector->getMapper('domain'); + if ($mapper->count(1) !== 0) { + if (is_null(self::$allowed_domains)) { + $allowed = Core::instance()->config('fetcher/allowed_domains', ''); + if (!empty($allowed)) { + self::$allowed_domains = "<{$allowed}>i"; + } + } + try { + $add = !empty(self::$allowed_domains) && preg_match(self::$allowed_domains, $data['fqdn']) === 1; + } catch (\ErrorException $e) { + $add = false; + Core::instance()->logger()->warning( + 'The allow_domains parameter in the settings has an incorrect regular expression value.' + ); + } + if (!$add) { + throw new SoftException('Failed to add an incoming report: unknown domain: ' . $data['fqdn']); + } + } + + $data['active'] = true; + $data['description'] = 'The domain was added automatically.'; + $mapper->save($data); + } + + /** + * Binds a nullable array to an SQL query as a json string + * + * @param PDOStatement $st DB statement object + * @param int $idx Bind position + * @param array $data JSON data or null + * + * @return void + */ + private static function sqlBindJson($st, int $idx, $data): void + { + if (is_null($data)) { + $val = null; + $type = \PDO::PARAM_NULL; + } else { + $val = json_encode($data); + $type = \PDO::PARAM_STR; + } + $st->bindValue($idx, $val, $type); + } + + /** + * Returns ORDER BY ... part of the SQL query + * + * @param array $order Key-value array with ordering options + * + * @return string + */ + private function sqlOrderList(array &$order): string + { + + $dir = $order['direction'] === 'ascent' ? 'ASC' : 'DESC'; + return " ORDER BY {$order['field']} {$dir}"; + } + + /** + * The valid filter item names + */ + private static $filters_available = [ + 'domain', 'month', 'before_time', 'organization', 'dkim', 'spf', 'status' + ]; + + /** + * Returns prepared filter data for sql queries + * + * @param array $filter Key-value array with filter options + * + * @return array + */ + private function prepareFilterData(array &$filter): array + { + $filters = []; + for ($i = 0; $i < 2; ++$i) { + $filters[] = [ + 'a_str' => [], + 'bindings' => [] + ]; + } + foreach (self::$filters_available as $fn) { + if (isset($filter[$fn])) { + $fv = $filter[$fn]; + switch (gettype($fv)) { + case 'string': + if (!empty($fv)) { + if ($fn == 'domain') { + $filters[0]['a_str'][] = 'rp.domain_id = ?'; + $d_data = [ 'fqdn' => $fv ]; + $this->connector->getMapper('domain')->fetch($d_data); + $filters[0]['bindings'][] = [ $d_data['id'], \PDO::PARAM_INT ]; + } elseif ($fn == 'month') { + $ma = explode('-', $fv); + if (count($ma) != 2) { + throw new SoftException('Report list filter: Incorrect date format'); + } + $year = (int)$ma[0]; + $month = (int)$ma[1]; + if ($year < 0 || $month < 1 || $month > 12) { + throw new SoftException('Report list filter: Incorrect month or year value'); + } + $filters[0]['a_str'][] = 'begin_time < ? AND end_time >= ?'; + $date1 = new DateTime("{$year}-{$month}-01"); + $date2 = (clone $date1)->modify('first day of next month'); + $date1->add(new \DateInterval('PT10S')); + $date2->sub(new \DateInterval('PT10S')); + $filters[0]['bindings'][] = [ $date2->format('Y-m-d H:i:s'), \PDO::PARAM_STR ]; + $filters[0]['bindings'][] = [ $date1->format('Y-m-d H:i:s'), \PDO::PARAM_STR ]; + } elseif ($fn == 'organization') { + $filters[0]['a_str'][] = 'org = ?'; + $filters[0]['bindings'][] = [ $fv, \PDO::PARAM_STR ]; + } elseif ($fn == 'dkim') { + if ($fv === Common::$align_res[0]) { + $val = 0; + } else { + $val = count(Common::$align_res) - 1; + if ($fv !== Common::$align_res[$val]) { + throw new SoftException('Report list filter: Incorrect DKIM value'); + } + } + $filters[1]['a_str'][] = 'dkim_align = ?'; + $filters[1]['bindings'][] = [ $val, \PDO::PARAM_INT ]; + } elseif ($fn == 'spf') { + if ($fv === Common::$align_res[0]) { + $val = 0; + } else { + $val = count(Common::$align_res) - 1; + if ($fv !== Common::$align_res[$val]) { + throw new SoftException('Report list filter: Incorrect SPF value'); + } + } + $filters[1]['a_str'][] = 'spf_align = ?'; + $filters[1]['bindings'][] = [ $val, \PDO::PARAM_INT ]; + } elseif ($fn == 'status') { + if ($fv === 'read') { + $val = true; + } elseif ($fv === 'unread') { + $val = false; + } else { + throw new SoftException('Report list filter: Incorrect status value'); + } + $filters[0]['a_str'][] = 'seen = ?'; + $filters[0]['bindings'][] = [ $val, \PDO::PARAM_BOOL ]; + } + } + break; + case 'object': + if ($fn == 'domain') { + $filters[0]['a_str'][] = 'rp.domain_id = ?'; + $filters[0]['bindings'][] = [ $fv->id(), \PDO::PARAM_INT ]; + } elseif ($fn == 'before_time') { + $filters[0]['a_str'][] = 'begin_time < ?'; + $filters[0]['bindings'][] = [ $fv->format('Y-m-d H:i:s'), \PDO::PARAM_STR ]; + } + break; + case 'integer': + if ($fn == 'domain') { + $filters[0]['a_str'][] = 'rp.domain_id = ?'; + $filters[0]['bindings'][] = [ $fv, \PDO::PARAM_INT ]; + } + break; + } + } + } + $f_data = []; + for ($i = 0; $i < count($filters); ++$i) { + $filter = &$filters[$i]; + if (count($filter['a_str']) > 0) { + $f_data[$i] = [ + 'str' => implode(' AND ', $filter['a_str']), + 'bindings' => $filter['bindings'] + ]; + } + unset($filter); + } + return $f_data; + } + + /** + * Returns the SQL condition for a filter by filter id + * + * @param array $f_data Array with prepared filter data + * @param string $prefix Prefix, which will be added to the beginning of the condition string, + * but only in the case when the condition string is not empty. + * @param int $f_id Index of the filter + * + * @return string the condition string + */ + private function sqlConditionList(array &$f_data, string $prefix, int $f_idx): string + { + return isset($f_data[$f_idx]) ? ($prefix . $f_data[$f_idx]['str']) : ''; + } + + /** + * Returns LIMIT ... part of the SQL query + * + * @param array $limit Key-value array with two keys: offset and count + * + * @return string + */ + private function sqlLimit(array &$limit): string + { + $res = ''; + if ($limit['count'] > 0) { + $res = ' LIMIT ?'; + if ($limit['offset'] > 0) { + $res .= ', ?'; + } + } + return $res; + } + + /** + * Binds the values of the filter and the limit to SQL query + * + * @param PDOStatement $st Prepared SQL statement to bind to + * @param array $f_data Array with prepared filter data + * @param array $limit Key-value array with two keys: offset and count + * + * @return void + */ + private function sqlBindValues($st, array &$f_data, array &$limit): void + { + $pos = 0; + if (isset($f_data[0])) { + $this->sqlBindFilterValues($st, $f_data, 0, $pos); + } + if (isset($f_data[1])) { + $this->sqlBindFilterValues($st, $f_data, 1, $pos); + } + if ($limit['count'] > 0) { + if ($limit['offset'] > 0) { + $st->bindValue(++$pos, $limit['offset'], \PDO::PARAM_INT); + } + $st->bindValue(++$pos, $limit['count'], \PDO::PARAM_INT); + } + } + + /** + * Binds the values of the specified filter item to SQL query + * + * @param PDOStatement $st Prepared SQL statement to bind to + * @param array $f_data Array with prepared filter data + * @param int $filter_idx Index of the filter to bind to + * @param int $bind_pos Start bind position (pointer). It will be increaded with each binding. + * + * @return void + */ + private function sqlBindFilterValues($st, array &$f_data, int $filter_idx, int &$bind_pos): void + { + foreach ($f_data[$filter_idx]['bindings'] as &$bv) { + $st->bindValue(++$bind_pos, $bv[0], $bv[1]); + } + } +} diff --git a/classes/Database/Sqlite/SettingMapper.php b/classes/Database/Sqlite/SettingMapper.php new file mode 100644 index 0000000..35fe300 --- /dev/null +++ b/classes/Database/Sqlite/SettingMapper.php @@ -0,0 +1,130 @@ +. + * + * ========================= + * + * This file contains the SettingMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\Database\SettingMapperInterface; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +/** + * SettingMapper class implementation for MariaDB + */ +class SettingMapper implements SettingMapperInterface +{ + private $connector = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Returns setting value as a string by key + * + * @param string $key + * + * @return string + */ + public function value(string $key): string + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT value FROM ' . $this->connector->tablePrefix('system') . ' WHERE key = ?' + ); + $st->bindValue(1, $key, \PDO::PARAM_STR); + $st->execute(); + if (!$res = $st->fetch(\PDO::FETCH_NUM)) { + throw new DatabaseNotFoundException('Setting not found: ' . $key); + } + $st->closeCursor(); + return $res[0]; + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get a setting', -1, $e); + } + } + + /** + * Returns a key-value array of the setting list like this: + * [ 'name1' => 'value1', 'name2' => 'value2' ] + * + * @return array + */ + public function list(): array + { + $res = []; + try { + $st = $this->connector->dbh()->query( + 'SELECT key, value FROM ' . $this->connector->tablePrefix('system') . ' ORDER BY key' + ); + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $res[$row[0]] = $row[1]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get a list of the settings', -1, $e); + } + return $res; + } + + /** + * Saves the setting to the database + * + * Updates the value of the setting in the database if the setting exists there or insert a new record otherwise. + * + * @param string $name Setting name + * @param string $value Setting value + * + * @return void + */ + public function save(string $name, string $value): void + { + $db = $this->connector->dbh(); + try { + $st = $db->prepare( + 'INSERT INTO ' . $this->connector->tablePrefix('system') . + ' (key, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?' + ); + $st->bindValue(1, $name, \PDO::PARAM_STR); + $st->bindValue(2, $value, \PDO::PARAM_STR); + $st->bindValue(3, $value, \PDO::PARAM_STR); + $st->execute(); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to update a setting', -1, $e); + } + } +} diff --git a/classes/Database/Sqlite/StatisticsMapper.php b/classes/Database/Sqlite/StatisticsMapper.php new file mode 100644 index 0000000..29da2d2 --- /dev/null +++ b/classes/Database/Sqlite/StatisticsMapper.php @@ -0,0 +1,222 @@ +. + * + * ========================= + * + * This file contains the StatisticsMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\Database\StatisticsMapperInterface; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; + +/** + * StatisticsMapper class implementation for MariaDB + */ +class StatisticsMapper implements StatisticsMapperInterface +{ + private $connector = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Returns summary information for the specified domain and date range + * + * @param Domain|null $domain Domain for which the information is needed. Null is for all domains. + * @param array $range Array with two dates + * + * @return array Array with Summary information: + * 'emails' => [ + * 'total' => total email processed (int) + * 'dkim_spf_aligned' => Both DKIM and SPF aligned (int) + * 'dkim_aligned' => Only DKIM aligned (int) + * 'spf_aligned' => Only SPF aligned (int) + * ]; + */ + public function summary($domain, array &$range): array + { + $is_domain = $domain ? true : false; + $db = $this->connector->dbh(); + try { + $st = $db->prepare( + 'SELECT SUM(rcount), SUM(CASE WHEN dkim_align = 2 AND spf_align = 2 THEN rcount ELSE 0 END),' + . ' SUM(CASE WHEN dkim_align = 2 AND spf_align <> 2 THEN rcount ELSE 0 END),' + . ' SUM(CASE WHEN dkim_align <> 2 AND spf_align = 2 THEN rcount ELSE 0 END)' + . ' FROM ' . $this->connector->tablePrefix('rptrecords') . ' AS rr' + . ' INNER JOIN ' . $this->connector->tablePrefix('reports') + . ' AS rp ON rr.report_id = rp.id' + . $this->sqlCondition($is_domain) + ); + $this->sqlBindValues($st, $domain, $range); + $st->execute(); + $row = $st->fetch(\PDO::FETCH_NUM); + $ems = [ + 'total' => intval($row[0]), + 'dkim_spf_aligned' => intval($row[1]), + 'dkim_aligned' => intval($row[2]), + 'spf_aligned' => intval($row[3]) + ]; + $st->closeCursor(); + + $st = $db->prepare( + 'SELECT COUNT(*) FROM (SELECT org FROM ' . $this->connector->tablePrefix('reports') . '' + . $this->sqlCondition($is_domain) . ' GROUP BY org) AS orgs' + ); + $this->sqlBindValues($st, $domain, $range); + $st->execute(); + $row = $st->fetch(\PDO::FETCH_NUM); + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get summary information', -1, $e); + } + + return [ + 'emails' => $ems, + 'organizations' => intval($row[0]) + ]; + } + + /** + * Returns a list of ip-addresses from which the e-mail messages were received, with some statistics for each one + * + * @param Domain|null $domain Domain for which the information is needed. Null is for all domains. + * @param array $range Array with two dates + * + * @return array A list of ip-addresses with fields ip, emails, dkim_aligned, spf_aligned + */ + public function ips($domain, array &$range): array + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT ip, SUM(rcount) AS rcount, SUM(CASE WHEN dkim_align = 2 THEN rcount ELSE 0 END) AS dkim_aligned,' + . ' SUM(CASE WHEN spf_align = 2 THEN rcount ELSE 0 END) AS spf_aligned' + . ' FROM ' . $this->connector->tablePrefix('rptrecords') . ' AS rr' + . ' INNER JOIN ' . $this->connector->tablePrefix('reports') + . ' AS rp ON rr.report_id = rp.id' + . $this->sqlCondition($domain ? true : false) . ' GROUP BY ip ORDER BY rcount DESC' + ); + $this->sqlBindValues($st, $domain, $range); + $st->execute(); + $res = []; + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $res[] = [ + 'ip' => inet_ntop($row[0]), + 'emails' => intval($row[1]), + 'dkim_aligned' => intval($row[2]), + 'spf_aligned' => intval($row[3]) + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get IPs summary information', -1, $e); + } + return $res; + } + + /** + * Returns a list of organizations that sent the reports with some statistics for each one + * + * @param Domain|null $domain Domain for which the information is needed. Null is for all domains. + * @param array $range Array with two dates + * + * @return array List of organizations with fields name, reports, emails + */ + public function organizations($domain, array &$range): array + { + try { + $st = $this->connector->dbh()->prepare( + 'SELECT org, COUNT(*), SUM(rr.rcount) AS rcount' + . ' FROM ' . $this->connector->tablePrefix('reports') . ' AS rp' + . ' INNER JOIN (SELECT report_id, SUM(rcount) AS rcount FROM ' + . $this->connector->tablePrefix('rptrecords') + . ' GROUP BY report_id) AS rr ON rp.id = rr.report_id' + . $this->sqlCondition($domain ? true : false) + . ' GROUP BY org ORDER BY rcount DESC' + ); + $this->sqlBindValues($st, $domain, $range); + $st->execute(); + $res = []; + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $res[] = [ + 'name' => $row[0], + 'reports' => intval($row[1]), + 'emails' => intval($row[2]) + ]; + } + $st->closeCursor(); + } catch (\PDOException $e) { + throw new DatabaseFatalException('Failed to get summary information of reporting organizations', -1, $e); + } + return $res; + } + + /** + * Returns a condition string for WHERE statement + * + * @param bool $with_domain Is it needed to add a condition for a domain + * + * @return string Condition string + */ + private function sqlCondition($with_domain): string + { + $res = ' WHERE '; + if ($with_domain) { + $res .= 'domain_id = ? AND '; + } + $res .= 'begin_time < ? AND end_time >= ?'; + return $res; + } + + /** + * Binds values for SQL queries + * + * @param PDOStatement $st PDO Statement to bind to + * @param Domain|null $domain Domain for the condition + * @param array $range Date range for the condition + * + * @return void + */ + private function sqlBindValues(object $st, $domain, array &$range): void + { + $pnum = 0; + if ($domain) { + $st->bindValue(++$pnum, $domain->id(), \PDO::PARAM_INT); + } + $ds1 = (clone $range['date1'])->add(new \DateInterval('PT10S'))->format('Y-m-d H:i:s'); + $ds2 = (clone $range['date2'])->sub(new \DateInterval('PT10S'))->format('Y-m-d H:i:s'); + $st->bindValue(++$pnum, $ds2, \PDO::PARAM_STR); + $st->bindValue(++$pnum, $ds1, \PDO::PARAM_STR); + } +} diff --git a/classes/Database/Sqlite/UpgraderMapper.php b/classes/Database/Sqlite/UpgraderMapper.php new file mode 100644 index 0000000..c5d24b7 --- /dev/null +++ b/classes/Database/Sqlite/UpgraderMapper.php @@ -0,0 +1,206 @@ +. + * + * ========================= + * + * This file contains the UpgraderMapper class + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Sqlite; + +use Liuch\DmarcSrg\Database\UpgraderMapperInterface; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; +use Liuch\DmarcSrg\Exception\DatabaseNotFoundException; + +/** + * UpgraderMapper class implementation for MariaDB + */ +class UpgraderMapper implements UpgraderMapperInterface +{ + private $connector = null; + + /** + * The constructor + * + * @param Connector $connector DatabaseConnector + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Starts upgrading the database structure + * + * @param string $target Target version of the database structure to upgrade to + * + * @return void + */ + public function go(string $target): void + { + try { + $cur_ver = $this->connector->getMapper('setting')->value('version'); + } catch (DatabaseNotFoundException $e) { + $cur_ver = 'null'; + } + + while ($cur_ver !== $target) { + if (!isset(self::$upways['ver_' . $cur_ver])) { + throw new SoftException( + "Upgrading failed: There is no way to upgrade from {$cur_ver} to {$target}" + ); + } + $um = self::$upways['ver_' . $cur_ver]; + $cur_ver = $this->$um(); + } + } + + /** + * Upgrades the database structure from None to 0.1 + * + * @return string New version of the database structure + */ + private function upNull(): string + { + $db = $this->connector->dbh(); + $db->beginTransaction(); + try { + $db->query( + 'INSERT INTO ' . $this->connector->tablePrefix('system') + . ' (key, value) VALUES ("version", "0.1")' + ); + $db->commit(); + } catch (\PDOException $e) { + $db->rollBack(); + throw $this->dbFatalException($e); + } catch (\Exception $e) { + $db->rollBack(); + throw $e; + } + return '0.1'; + } + + /** + * Upgrades the database structure from 0.1 to 1.0 + * + * @return string New version of the database structure + */ + private function up01(): string + { + $db = $this->connector->dbh(); + // Transaction would be useful here but it doesn't work with ALTER TABLE in MySQL/MariaDB + try { + $dom_tn = $this->connector->tablePrefix('domains'); + if (!$this->columnExists($db, $dom_tn, 'active')) { + $db->query( + 'ALTER TABLE ' . $dom_tn . ' ADD COLUMN active INTEGER NOT NULL AFTER fqdn' + ); + } + if (!$this->columnExists($db, $dom_tn, 'created_time')) { + $db->query( + 'ALTER TABLE ' . $dom_tn . ' ADD COLUMN created_time TEXT NOT NULL' + ); + } + if (!$this->columnExists($db, $dom_tn, 'updated_time')) { + $db->query( + 'ALTER TABLE ' . $dom_tn . ' ADD COLUMN updated_time TEXT NOT NULL' + ); + } + $db->query( + 'UPDATE ' . $dom_tn . ' SET active = TRUE, created_time = NOW(), updated_time = NOW()' + ); + $db->query( + 'UPDATE ' . $this->connector->tablePrefix('system') . ' SET value = "1.0" WHERE key = "version"' + ); + } catch (\PDOException $e) { + throw $this->dbFatalException($e); + } + return '1.0'; + } + + /** + * Upgrades the database structure from 1.0 to 2.0 + * + * @return string New version of the database structure + */ + private function up10(): string + { + $db = $this->connector->dbh(); + // Transaction would be useful here but it doesn't work with ALTER TABLE in MySQL/MariaDB + try { + $sys_tn = $this->connector->tablePrefix('system'); + // $db->query( + // 'ALTER TABLE ' . $sys_tn . ' MODIFY COLUMN key varchar(64) NOT NULL' + // ); + $db->query( + 'UPDATE ' . $sys_tn . ' SET value = "2.0" WHERE key = "version"' + ); + } catch (\PDOException $e) { + throw $this->dbFatalException($e); + } + return '2.0'; + } + + /** + * Checks if the spefied column exists in the spefied table of the database + * + * @param object $db Connection handle of the database + * @param string $table Table name with the prefix + * @param string $columb Column name + * + * @return bool + */ + private function columnExists($db, string $table, string $column): bool + { + $st = $db->prepare( + 'PRAGMA table_info(?)' + ); + $st->bindValue(2, $table, \PDO::PARAM_STR); + $st->execute(); + $res = $st->fetch(\PDO::FETCH_ASSOC); + $st->closeCursor(); + return $res ? true : false; + } + + /** + * Return an instance of DatabaseFatalException + * + * @param Exception $e The original exception + * + * @return DatabaseFatalException + */ + private function dbFatalException($e) + { + return new DatabaseFatalException('Failed to upgrade the database structure', -1, $e); + } + + private static $upways = [ + 'ver_null' => 'upNull', + 'ver_0.1' => 'up01', + 'ver_1.0' => 'up10' + ]; +}