%PDF- %PDF-
Direktori : /www/varak.net/dmarc.varak.net/classes/Database/Common/ |
Current File : //www/varak.net/dmarc.varak.net/classes/Database/Common/CommonReportMapper.php |
<?php /** * dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports. * Copyright (C) 2022-2025 Aleksey Andreev (liuch) * * Available at: * https://github.com/liuch/dmarc-srg * * This program 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. * * 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. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see <http://www.gnu.org/licenses/>. * * ========================= * * This file contains the CommonReportMapper 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\Common; use Liuch\DmarcSrg\Core; use Liuch\DmarcSrg\Common; use Liuch\DmarcSrg\DateTime; use Liuch\DmarcSrg\Report\ReportData; 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; /** * Universal implementation of ReportMapper class */ class CommonReportMapper implements ReportMapperInterface { /** @var \Liuch\DmarcSrg\Database\DatabaseConnector */ private $connector = null; private static $allowed_domains = null; /** * The constructor * * @param \Liuch\DmarcSrg\Database\DatabaseConnector $connector DatabaseConnector instance of the current database */ public function __construct(object $connector) { $this->connector = $connector; } /** * Fetches report data from the database and stores it in the passed array * * @param ReportData $data Report data class. * To identify the report it must contain at least these fields: * `report_id` - External report id from the xml file * `domain` - Fully Qualified Domain Name without a trailing dot * `org_name` - Organization name * `date=>begin` - Begin timestamp of the report time range * * @return void */ public function fetch($data): void { $db = $this->connector->dbh(); try { $st = $db->prepare( 'SELECT rp.id, end_time, loaded_time, email, extra_contact_info,' . ' error_string, policy_adkim, policy_aspf, policy_p, policy_sp, policy_np,' . ' 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 begin_time = ? AND org = ? AND external_id = ?' ); $st->bindValue(1, $data->domain, \PDO::PARAM_STR); $st->bindValue(2, $data->date['begin']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); $st->bindValue(3, $data->org_name, \PDO::PARAM_STR); $st->bindValue(4, $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['end'] = new DateTime($res[1]); $data->loaded_time = new DateTime($res[2]); $data->email = $res[3]; $data->extra_contact_info = $res[4]; $data->error_string = json_decode($res[5] ?? '', true); $data->policy = [ 'adkim' => $res[6], 'aspf' => $res[7], 'p' => $res[8], 'sp' => $res[9], 'np' => $res[10], 'pct' => $res[11], 'fo' => $res[12] ]; $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 ReportData $data Report data * * @return void */ public function save($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'); } $user_id = Core::instance()->getCurrentUser()->id(); if ($user_id !== 0 && !$domain_mapper->isAssigned($domain_data, $user_id)) { // The domain exists but is not assigned to the current user $this->unknownDomain($domain_data); } } catch (DatabaseNotFoundException $e) { // The domain is not found. Let's try to add it automatically. if (Core::instance()->getCurrentUser()->id() !== 0) { $this->unknownDomain($domain_data); } $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_np, policy_pct, policy_fo, seen)' . ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' ); $st->bindValue(1, $domain_data['id'], \PDO::PARAM_INT); $st->bindValue(2, $data->date['begin']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); $st->bindValue(3, $data->date['end']->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_name, \PDO::PARAM_STR); $st->bindValue(6, $data->report_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['np'], \PDO::PARAM_STR); $st->bindValue(15, $data->policy['pct'], \PDO::PARAM_STR); $st->bindValue(16, $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['count'], \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 ReportData $data Report data * @param string $name Name of property to change. Currently only `seen` is supported. * @param mixed $value New property value * * @return void */ public function setProperty($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 begin_time = ? AND org = ? AND external_id = ?' ); $st->bindValue(1, $value, \PDO::PARAM_BOOL); $st->bindValue(2, $data->domain, \PDO::PARAM_STR); $st->bindValue(3, $data->date['begin']->format('Y-m-d H:i:s'), \PDO::PARAM_STR); $st->bindValue(4, $data->org_name, \PDO::PARAM_STR); $st->bindValue(5, $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` * @param int $user_id User ID to retrieve the list for * * @return array */ public function list(array &$filter, array &$order, array &$limit, int $user_id): array { $db = $this->connector->dbh(); $list = []; $f_data = $this->prepareFilterData($filter, 'rp'); $user_doms = $this->sqlUserRestriction($user_id, 'd.id'); $order_str = $this->sqlOrderList($order, 'rp.id'); $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,' . ' SUM(IF(dkim_align = 0, rcount, 0)) AS dkim_align_fail,' . ' SUM(IF(dkim_align = 1, rcount, 0)) AS dkim_align_unknown,' . ' SUM(IF(spf_align = 0, rcount, 0)) AS spf_align_fail,' . ' SUM(IF(spf_align = 1, rcount, 0)) AS spf_align_unknown,' . ' SUM(IF(disposition = 0, rcount, 0)) AS rejected,' . ' SUM(IF(disposition = 1, rcount, 0)) AS quarantined' . ' FROM ' . $this->connector->tablePrefix('rptrecords') . ' AS rr RIGHT JOIN (SELECT rp.id, org, begin_time, end_time, external_id,' . ' fqdn, seen FROM ' . $this->connector->tablePrefix('reports') . ' AS rp INNER JOIN ' . $this->connector->tablePrefix('domains') . ' AS d ON d.id = rp.domain_id' . $user_doms . $cond_str0 . $order_str . ') AS rp ON rp.id = rr.report_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)) { $messages = intval($row[6]); $dkim_fail = intval($row[7]); $dkim_unkn = intval($row[8]); $spf_fail = intval($row[9]); $spf_unkn = intval($row[10]); $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' => $messages, 'dkim_align' => [ 'fail' => $dkim_fail, 'unknown' => $dkim_unkn, 'pass' => $messages - $dkim_fail - $dkim_unkn ], 'spf_align' => [ 'fail' => $spf_fail, 'unknown' => $spf_unkn, 'pass' => $messages - $spf_fail - $spf_unkn ], 'rejected' => intval($row[11]), 'quarantined' => intval($row[12]) ]; } $st->closeCursor(); } catch (\PDOException $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` * @param int $user_id User ID to count reports for * * @return int */ public function count(array &$filter, array &$limit, int $user_id): int { $cnt = 0; $f_data = $this->prepareFilterData($filter, 'rp'); try { if (isset($filter['dkim']) || isset($filter['spf']) || isset($filter['disposition'])) { $st = $this->connector->dbh()->prepare( 'SELECT COUNT(*) FROM (' . 'SELECT SUM(IF(dkim_align = 0, rcount, 0)) AS dkim_align_fail,' . ' SUM(IF(dkim_align = 1, rcount, 0)) AS dkim_align_unknown,' . ' SUM(IF(spf_align = 0, rcount, 0)) AS spf_align_fail,' . ' SUM(IF(spf_align = 1, rcount, 0)) AS spf_align_unknown,' . ' SUM(IF(disposition = 0, rcount, 0)) AS rejected,' . ' SUM(IF(disposition = 1, rcount, 0)) AS quarantined' . ' FROM ' . $this->connector->tablePrefix('rptrecords') . ' AS rr RIGHT JOIN (SELECT rp.id FROM ' . $this->connector->tablePrefix('reports') . ' AS rp INNER JOIN ' . $this->connector->tablePrefix('domains') . ' AS d ON d.id = rp.domain_id' . $this->sqlUserRestriction($user_id, 'd.id') . $this->sqlConditionList($f_data, ' AND ', 0) . ') AS rp ON rp.id = rr.report_id GROUP BY rp.id' . $this->sqlConditionList($f_data, ' HAVING ', 1) . ') AS ct' ); } elseif ($user_id) { $st = $this->connector->dbh()->prepare( 'SELECT COUNT(*) FROM ' . $this->connector->tablePrefix('reports') . ' AS rp' . ' INNER JOIN ' . $this->connector->tablePrefix('domains') . ' AS d ON d.id = rp.domain_id' . $this->sqlUserRestriction($user_id, 'd.id') . $this->sqlConditionList($f_data, ' AND ', 0) ); } else { $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`, `spf` and `disposition` 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 { if (Core::instance()->getCurrentUser()->id()) { throw new LogicException('Attempted deletion of reports by non-admin user'); } $f_data = $this->prepareFilterData($filter, ''); $cond_str = $this->sqlConditionList($f_data, ' WHERE ', 0); $order_str = $this->sqlOrderList($order, 'id'); $limit_str = $this->sqlLimit($limit); $db = $this->connector->dbh(); if (!$db->inTransaction()) { $db->beginTransaction(); $nested = false; } else { $nested = true; } 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(); if (!$nested) { $db->commit(); } } catch (\PDOException $e) { if (!$nested) { $db->rollBack(); } throw new DatabaseFatalException('Failed to delete reports', -1, $e); } catch (\Exception $e) { if (!$nested) { $db->rollBack(); } throw $e; } } /** * Returns a list of months with years of the form: 'yyyy-mm' for which there is at least one report * * @param int $user_id User ID to retrieve the list for * * @return array */ public function months(int $user_id): array { $res = []; $rep_tn = $this->connector->tablePrefix('reports'); try { $ud = $this->sqlUserRestriction($user_id, 'rp.domain_id'); $st = $this->connector->dbh()->query( 'SELECT DISTINCT DATE_FORMAT(date, \'%Y-%m\') AS month FROM' . ' ((SELECT DISTINCT begin_time AS date FROM ' . $rep_tn . ' AS rp' . $ud . ') UNION (SELECT DISTINCT end_time AS date FROM ' . $rep_tn . ' AS rp' . $ud . ')) 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 * * @param int $user_id User ID to retrieve the list for * * @return array */ public function organizations(int $user_id): array { $res = []; $rep_tn = $this->connector->tablePrefix('reports'); try { $ud = $this->sqlUserRestriction($user_id, 'rp.domain_id'); $st = $this->connector->dbh()->query( 'SELECT DISTINCT org FROM ' . $rep_tn . " AS rp{$ud} 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 the part of sql query restricting the select result by user_id * * @param int $user_id User ID * @param string $column Column name to join * * @return string */ private function sqlUserRestriction(int $user_id, string $column): string { if (!$user_id) { return ''; } return ' INNER JOIN ' . $this->connector->tablePrefix('userdomains') . ' AS ud ON ' . $column . ' = ud.domain_id WHERE user_id = ' . $user_id; } /** * 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 * * @throws SoftException * * @return void */ private function insertDomain(array &$data, $mapper): void { $core = Core::instance(); if (is_null(self::$allowed_domains)) { $allowed = $core->config('fetcher/allowed_domains', ''); if (!empty($allowed)) { self::$allowed_domains = "<{$allowed}>i"; } } $add_f = false; if ($mapper->count(0, 1) === 0) { $add_f = true; } else { try { $add_f = !empty(self::$allowed_domains) && preg_match(self::$allowed_domains, $data['fqdn']) === 1; } catch (\ErrorException $e) { $core->logger()->warning( 'The allow_domains parameter in the settings has an incorrect regular expression value.' ); } } if (!$add_f) { $this->unknownDomain($data); } $data['active'] = true; $data['description'] = 'The domain was added automatically.'; $mapper->save($data); } /** * Throws an exception Unknown domain * * @param array $data Domain data * * @throws SoftException * * @return void */ private function unknownDomain(array &$data): void { $msg = 'Failed to add an incoming report: unknown domain'; if (!empty($data['fqdn'])) { $msg .= " {$data['fqdn']}"; } throw new SoftException($msg); } /** * 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|null $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 * @param string $tail_field Table field for predictable sorting * * @return string */ private function sqlOrderList(array &$order, string $tail_field): string { if (count($order) == 0) { return ''; } $dir = $order['direction'] === 'ascent' ? 'ASC' : 'DESC'; $res = " ORDER BY {$order['field']} {$dir}"; if (!empty($tail_field)) { $res .= ", {$tail_field} {$dir}"; } return $res; } /** * The valid filter item names */ private static $filters_available = [ 'domain', 'month', 'before_time', 'organization', 'dkim', 'spf', 'disposition', 'status' ]; /** * Returns prepared filter data for sql queries * * @param array $filter Key-value array with filter options * @param string $tname Table name prefix for the domain_id field * * @return array */ private function prepareFilterData(array &$filter, string $tname = ''): array { $filters = []; for ($i = 0; $i < 2; ++$i) { $filters[] = [ 'a_str' => [], 'bindings' => [] ]; } if (!empty($tname)) { $tname = "{$tname}."; } 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'][] = $tname . '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 (!in_array($fv, Common::$align_res, true)) { throw new SoftException('Report list filter: Incorrect DKIM value'); } if ($fv === 'pass') { $filters[1]['a_str'][] = 'dkim_align_fail = 0 AND dkim_align_unknown = 0'; } else { $filters[1]['a_str'][] = "dkim_align_{$fv} > 0"; } } elseif ($fn == 'spf') { if (!in_array($fv, Common::$align_res, true)) { throw new SoftException('Report list filter: Incorrect SPF value'); } if ($fv === 'pass') { $filters[1]['a_str'][] = 'spf_align_fail = 0 AND spf_align_unknown = 0'; } else { $filters[1]['a_str'][] = "spf_align_{$fv} > 0"; } } elseif ($fn == 'disposition') { switch ($fv) { case 'none': $str = 'rejected = 0 AND quarantined = 0'; break; case 'quarantine': $str = 'quarantined > 0'; break; case 'reject': $str = 'rejected > 0'; break; default: throw new SoftException('Report list filter: Incorrect value of disposition'); } $filters[1]['a_str'][] = $str; } 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'][] = $tname . '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'][] = $tname . '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_idx 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 increased 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]); } } }