|
@@ -16,7 +16,7 @@ use Doctrine\DBAL\ParameterType;
|
|
|
* row-level locking — `ip_scores` rows churn fast and the bulk recompute
|
|
* row-level locking — `ip_scores` rows churn fast and the bulk recompute
|
|
|
* (M05) is the authority anyway.
|
|
* (M05) is the authority anyway.
|
|
|
*/
|
|
*/
|
|
|
-final class IpScoreRepository extends RepositoryBase
|
|
|
|
|
|
|
+class IpScoreRepository extends RepositoryBase
|
|
|
{
|
|
{
|
|
|
public function upsert(
|
|
public function upsert(
|
|
|
string $ipBin,
|
|
string $ipBin,
|
|
@@ -68,6 +68,264 @@ final class IpScoreRepository extends RepositoryBase
|
|
|
return $value === false ? null : (float) $value;
|
|
return $value === false ? null : (float) $value;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * True if any (ip_bin, category_id) row exists with a non-zero score.
|
|
|
|
|
+ * The single-IP "is this scored?" check for the admin UI.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function hasAnyScore(string $ipBin): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ $stmt = $this->connection()->prepare(
|
|
|
|
|
+ 'SELECT 1 FROM ip_scores WHERE ip_bin = :ip AND score > 0 LIMIT 1'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
|
|
|
|
|
+
|
|
|
|
|
+ return $stmt->executeQuery()->fetchOne() !== false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * All score rows for one IP — used by the admin IP-detail page to
|
|
|
|
|
+ * show the score-per-category breakdown.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return list<array{category_id: int, score: float, last_report_at: ?string, report_count_30d: int, recomputed_at: ?string}>
|
|
|
|
|
+ */
|
|
|
|
|
+ public function scoresForIp(string $ipBin): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $stmt = $this->connection()->prepare(
|
|
|
|
|
+ 'SELECT category_id, score, last_report_at, report_count_30d, recomputed_at '
|
|
|
|
|
+ . 'FROM ip_scores WHERE ip_bin = :ip ORDER BY score DESC'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
|
|
|
|
|
+
|
|
|
|
|
+ /** @var list<array<string, mixed>> $rows */
|
|
|
|
|
+ $rows = $stmt->executeQuery()->fetchAllAssociative();
|
|
|
|
|
+ $out = [];
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ $out[] = [
|
|
|
|
|
+ 'category_id' => (int) $row['category_id'],
|
|
|
|
|
+ 'score' => (float) $row['score'],
|
|
|
|
|
+ 'last_report_at' => $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
|
|
|
|
|
+ 'report_count_30d' => (int) $row['report_count_30d'],
|
|
|
|
|
+ 'recomputed_at' => $row['recomputed_at'] !== null ? (string) $row['recomputed_at'] : null,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Paginated IP search.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Filters:
|
|
|
|
|
+ * - q: ip_text substring (uses LIKE 'q%' if it looks like an IP prefix,
|
|
|
|
|
+ * LIKE '%q%' otherwise — slower but small dataset)
|
|
|
|
|
+ * - categoryId: only count IPs with a row in this category (score > 0)
|
|
|
|
|
+ * - minScore / maxScore: filter MAX(score) per IP via HAVING
|
|
|
|
|
+ * - countryCode / asn: filter via LEFT JOIN on ip_enrichment
|
|
|
|
|
+ * - statusManualBins / statusAllowBins / statusScored / statusClean:
|
|
|
|
|
+ * pre-resolved sets for the status filter; the controller maps the
|
|
|
|
|
+ * `status=` query value to one of these.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Returns paged rows with aggregate columns and a total count.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param array{
|
|
|
|
|
+ * q?: ?string,
|
|
|
|
|
+ * category_id?: ?int,
|
|
|
|
|
+ * min_score?: ?float,
|
|
|
|
|
+ * max_score?: ?float,
|
|
|
|
|
+ * country?: ?string,
|
|
|
|
|
+ * asn?: ?int,
|
|
|
|
|
+ * status?: ?string,
|
|
|
|
|
+ * manual_bins?: list<string>,
|
|
|
|
|
+ * allow_bins?: list<string>,
|
|
|
|
|
+ * } $filters
|
|
|
|
|
+ * @return array{
|
|
|
|
|
+ * items: list<array{ip_bin: string, ip_text: string, max_score: float, last_report_at: ?string, pair_count: int}>,
|
|
|
|
|
+ * total: int,
|
|
|
|
|
+ * }
|
|
|
|
|
+ */
|
|
|
|
|
+ public function searchIps(array $filters, int $limit, int $offset): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $where = [];
|
|
|
|
|
+ $params = [];
|
|
|
|
|
+
|
|
|
|
|
+ $q = $filters['q'] ?? null;
|
|
|
|
|
+ if (is_string($q) && $q !== '') {
|
|
|
|
|
+ // Prefix path: bare IPv4 octets like "203." or "203.0.113." use prefix.
|
|
|
|
|
+ // For everything else fall back to substring; small enough to scan.
|
|
|
|
|
+ if (preg_match('/^[\da-fA-F:.]+$/', $q) === 1) {
|
|
|
|
|
+ $where[] = 's.ip_text LIKE :q';
|
|
|
|
|
+ $params['q'] = $q . '%';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $where[] = 's.ip_text LIKE :q';
|
|
|
|
|
+ $params['q'] = '%' . $q . '%';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $catId = $filters['category_id'] ?? null;
|
|
|
|
|
+ if (is_int($catId) && $catId > 0) {
|
|
|
|
|
+ $where[] = 's.category_id = :catId';
|
|
|
|
|
+ $where[] = 's.score > 0';
|
|
|
|
|
+ $params['catId'] = $catId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $country = $filters['country'] ?? null;
|
|
|
|
|
+ $asn = $filters['asn'] ?? null;
|
|
|
|
|
+ $joinEnrichment = (is_string($country) && $country !== '') || (is_int($asn) && $asn > 0);
|
|
|
|
|
+ if (is_string($country) && $country !== '') {
|
|
|
|
|
+ $where[] = 'e.country_code = :country';
|
|
|
|
|
+ $params['country'] = strtoupper($country);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (is_int($asn) && $asn > 0) {
|
|
|
|
|
+ $where[] = 'e.asn = :asn';
|
|
|
|
|
+ $params['asn'] = $asn;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $manualBins = $filters['manual_bins'] ?? [];
|
|
|
|
|
+ $allowBins = $filters['allow_bins'] ?? [];
|
|
|
|
|
+ $status = $filters['status'] ?? null;
|
|
|
|
|
+ if (is_string($status)) {
|
|
|
|
|
+ switch ($status) {
|
|
|
|
|
+ case 'manual':
|
|
|
|
|
+ if ($manualBins === []) {
|
|
|
|
|
+ // No matches possible — short-circuit.
|
|
|
|
|
+ return ['items' => [], 'total' => 0];
|
|
|
|
|
+ }
|
|
|
|
|
+ [$inExpr, $inParams] = $this->buildBinIn($manualBins, 'mb');
|
|
|
|
|
+ $where[] = 's.ip_bin IN (' . $inExpr . ')';
|
|
|
|
|
+ $params = array_merge($params, $inParams);
|
|
|
|
|
+
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'allowlisted':
|
|
|
|
|
+ if ($allowBins === []) {
|
|
|
|
|
+ return ['items' => [], 'total' => 0];
|
|
|
|
|
+ }
|
|
|
|
|
+ [$inExpr, $inParams] = $this->buildBinIn($allowBins, 'ab');
|
|
|
|
|
+ $where[] = 's.ip_bin IN (' . $inExpr . ')';
|
|
|
|
|
+ $params = array_merge($params, $inParams);
|
|
|
|
|
+
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'scored':
|
|
|
|
|
+ $where[] = 's.score > 0';
|
|
|
|
|
+
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'clean':
|
|
|
|
|
+ if ($manualBins !== []) {
|
|
|
|
|
+ [$inExpr, $inParams] = $this->buildBinIn($manualBins, 'mb');
|
|
|
|
|
+ $where[] = 's.ip_bin NOT IN (' . $inExpr . ')';
|
|
|
|
|
+ $params = array_merge($params, $inParams);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($allowBins !== []) {
|
|
|
|
|
+ [$inExpr, $inParams] = $this->buildBinIn($allowBins, 'ab');
|
|
|
|
|
+ $where[] = 's.ip_bin NOT IN (' . $inExpr . ')';
|
|
|
|
|
+ $params = array_merge($params, $inParams);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $minScore = $filters['min_score'] ?? null;
|
|
|
|
|
+ $maxScore = $filters['max_score'] ?? null;
|
|
|
|
|
+ $having = [];
|
|
|
|
|
+ if ($minScore !== null) {
|
|
|
|
|
+ $having[] = 'MAX(s.score) >= :minScore';
|
|
|
|
|
+ $params['minScore'] = number_format($minScore, 4, '.', '');
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($maxScore !== null) {
|
|
|
|
|
+ $having[] = 'MAX(s.score) <= :maxScore';
|
|
|
|
|
+ $params['maxScore'] = number_format($maxScore, 4, '.', '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $sql = 'SELECT s.ip_bin, s.ip_text, '
|
|
|
|
|
+ . 'MAX(s.score) AS max_score, '
|
|
|
|
|
+ . 'MAX(s.last_report_at) AS last_report_at, '
|
|
|
|
|
+ . 'COUNT(*) AS pair_count '
|
|
|
|
|
+ . 'FROM ip_scores s ';
|
|
|
|
|
+ if ($joinEnrichment) {
|
|
|
|
|
+ $sql .= 'LEFT JOIN ip_enrichment e ON e.ip_bin = s.ip_bin ';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($where !== []) {
|
|
|
|
|
+ $sql .= 'WHERE ' . implode(' AND ', $where) . ' ';
|
|
|
|
|
+ }
|
|
|
|
|
+ $sql .= 'GROUP BY s.ip_bin, s.ip_text ';
|
|
|
|
|
+ if ($having !== []) {
|
|
|
|
|
+ $sql .= 'HAVING ' . implode(' AND ', $having) . ' ';
|
|
|
|
|
+ }
|
|
|
|
|
+ $sql .= 'ORDER BY max_score DESC, s.ip_bin ASC ';
|
|
|
|
|
+ $sql .= 'LIMIT :limit OFFSET :offset';
|
|
|
|
|
+
|
|
|
|
|
+ // Threading binary IN-list params through: DBAL needs LARGE_OBJECT
|
|
|
|
|
+ // type for `ip_bin` blob comparisons on MySQL; SQLite is happy
|
|
|
|
|
+ // with strings either way. Mark all parameters whose names start
|
|
|
|
|
+ // with "mb" (manual_bins) or "ab" (allow_bins).
|
|
|
|
|
+ $itemsParams = $params;
|
|
|
|
|
+ $itemsParams['limit'] = $limit;
|
|
|
|
|
+ $itemsParams['offset'] = $offset;
|
|
|
|
|
+ $itemTypes = ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER];
|
|
|
|
|
+ foreach (array_keys($params) as $name) {
|
|
|
|
|
+ if (str_starts_with($name, 'mb') || str_starts_with($name, 'ab')) {
|
|
|
|
|
+ $itemTypes[$name] = ParameterType::LARGE_OBJECT;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** @var list<array<string, mixed>> $rows */
|
|
|
|
|
+ $rows = $this->connection()->fetchAllAssociative($sql, $itemsParams, $itemTypes);
|
|
|
|
|
+
|
|
|
|
|
+ // Count: wrap the SELECT (without ORDER/LIMIT) in a subquery.
|
|
|
|
|
+ $countSql = 'SELECT COUNT(*) FROM (SELECT s.ip_bin FROM ip_scores s ';
|
|
|
|
|
+ if ($joinEnrichment) {
|
|
|
|
|
+ $countSql .= 'LEFT JOIN ip_enrichment e ON e.ip_bin = s.ip_bin ';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($where !== []) {
|
|
|
|
|
+ $countSql .= 'WHERE ' . implode(' AND ', $where) . ' ';
|
|
|
|
|
+ }
|
|
|
|
|
+ $countSql .= 'GROUP BY s.ip_bin';
|
|
|
|
|
+ if ($having !== []) {
|
|
|
|
|
+ $countSql .= ' HAVING ' . implode(' AND ', $having);
|
|
|
|
|
+ }
|
|
|
|
|
+ $countSql .= ') t';
|
|
|
|
|
+ $countTypes = [];
|
|
|
|
|
+ foreach (array_keys($params) as $name) {
|
|
|
|
|
+ if (str_starts_with($name, 'mb') || str_starts_with($name, 'ab')) {
|
|
|
|
|
+ $countTypes[$name] = ParameterType::LARGE_OBJECT;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ $total = (int) $this->connection()->fetchOne($countSql, $params, $countTypes);
|
|
|
|
|
+
|
|
|
|
|
+ $items = [];
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ $items[] = [
|
|
|
|
|
+ 'ip_bin' => (string) $row['ip_bin'],
|
|
|
|
|
+ 'ip_text' => (string) $row['ip_text'],
|
|
|
|
|
+ 'max_score' => (float) $row['max_score'],
|
|
|
|
|
+ 'last_report_at' => $row['last_report_at'] !== null ? (string) $row['last_report_at'] : null,
|
|
|
|
|
+ 'pair_count' => (int) $row['pair_count'],
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ['items' => $items, 'total' => $total];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Build a parametrised `(:p0, :p1, …)` clause + matching params map for
|
|
|
|
|
+ * a list of binary IPs. Used by the search's status filter.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param list<string> $bins
|
|
|
|
|
+ * @return array{0: string, 1: array<string, string>}
|
|
|
|
|
+ */
|
|
|
|
|
+ private function buildBinIn(array $bins, string $prefix): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $names = [];
|
|
|
|
|
+ $params = [];
|
|
|
|
|
+ foreach ($bins as $i => $bin) {
|
|
|
|
|
+ $name = $prefix . $i;
|
|
|
|
|
+ $names[] = ':' . $name;
|
|
|
|
|
+ $params[$name] = $bin;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [implode(', ', $names), $params];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* All (ip_bin, ip_text, category_id) currently in `ip_scores` whose
|
|
* All (ip_bin, ip_text, category_id) currently in `ip_scores` whose
|
|
|
* `recomputed_at` is older than `$staleBefore` (NULLs counted as stale
|
|
* `recomputed_at` is older than `$staleBefore` (NULLs counted as stale
|