ManualBlockRepository.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Infrastructure\ManualBlock;
  4. use App\Domain\Ip\Cidr;
  5. use App\Domain\Ip\IpAddress;
  6. use App\Domain\ManualBlock\ManualBlock;
  7. use App\Infrastructure\Db\RepositoryBase;
  8. use DateTimeImmutable;
  9. use DateTimeZone;
  10. use Doctrine\DBAL\ParameterType;
  11. /**
  12. * DBAL gateway for `manual_blocks`. The table holds both single-IP and CIDR
  13. * subnet entries in one shape (per SPEC §4); `kind` decides which set of
  14. * columns is populated.
  15. *
  16. * No update path: an admin removes and re-adds. Soft-delete is unnecessary
  17. * — manual_blocks is itself the override layer, not a long-lived audit
  18. * surface (audit lives in `audit_log`, M12).
  19. */
  20. final class ManualBlockRepository extends RepositoryBase
  21. {
  22. /**
  23. * Find a single-IP manual block by exact `ip_bin` match. Used by the
  24. * admin IP-detail endpoint to render the manual-block panel.
  25. */
  26. public function findByIpBin(string $ipBin): ?ManualBlock
  27. {
  28. $row = $this->fetchByIpBin('manual_blocks', $ipBin);
  29. if ($row === null) {
  30. return null;
  31. }
  32. // The base helper matches WHERE ip_bin = ?, but `manual_blocks`
  33. // stores network entries with `ip_bin = NULL` and a separate
  34. // `network_bin`, so we additionally filter to kind=ip rows.
  35. if (($row['kind'] ?? '') !== ManualBlock::KIND_IP) {
  36. return null;
  37. }
  38. return self::hydrate($row);
  39. }
  40. public function findById(int $id): ?ManualBlock
  41. {
  42. /** @var array<string, mixed>|false $row */
  43. $row = $this->connection()->fetchAssociative(
  44. 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
  45. . 'FROM manual_blocks WHERE id = :id',
  46. ['id' => $id]
  47. );
  48. return $row === false ? null : self::hydrate($row);
  49. }
  50. /**
  51. * @param array{kind?: ?string} $filters
  52. * @return list<ManualBlock>
  53. */
  54. public function list(?int $limit, ?int $offset, array $filters = []): array
  55. {
  56. $sql = 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
  57. . 'FROM manual_blocks';
  58. $params = [];
  59. $types = [];
  60. $where = [];
  61. if (isset($filters['kind']) && $filters['kind'] !== null) {
  62. $where[] = 'kind = :kind';
  63. $params['kind'] = $filters['kind'];
  64. }
  65. if ($where !== []) {
  66. $sql .= ' WHERE ' . implode(' AND ', $where);
  67. }
  68. $sql .= ' ORDER BY id DESC';
  69. if ($limit !== null) {
  70. $sql .= ' LIMIT :limit';
  71. $params['limit'] = $limit;
  72. $types['limit'] = ParameterType::INTEGER;
  73. if ($offset !== null) {
  74. $sql .= ' OFFSET :offset';
  75. $params['offset'] = $offset;
  76. $types['offset'] = ParameterType::INTEGER;
  77. }
  78. }
  79. /** @var list<array<string, mixed>> $rows */
  80. $rows = $this->connection()->fetchAllAssociative($sql, $params, $types);
  81. return array_map(self::hydrate(...), $rows);
  82. }
  83. public function count(?string $kindFilter = null): int
  84. {
  85. if ($kindFilter !== null) {
  86. return (int) $this->connection()->fetchOne(
  87. 'SELECT COUNT(*) FROM manual_blocks WHERE kind = :kind',
  88. ['kind' => $kindFilter]
  89. );
  90. }
  91. return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM manual_blocks');
  92. }
  93. public function createIp(
  94. IpAddress $ip,
  95. ?string $reason,
  96. ?DateTimeImmutable $expiresAt,
  97. ?int $createdByUserId,
  98. ): int {
  99. $this->insertRow('manual_blocks', [
  100. 'kind' => ManualBlock::KIND_IP,
  101. 'ip_bin' => $ip->binary(),
  102. 'network_bin' => null,
  103. 'prefix_length' => null,
  104. 'reason' => $reason,
  105. 'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
  106. 'created_by_user_id' => $createdByUserId,
  107. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  108. return (int) $this->connection()->lastInsertId();
  109. }
  110. public function createSubnet(
  111. Cidr $cidr,
  112. ?string $reason,
  113. ?DateTimeImmutable $expiresAt,
  114. ?int $createdByUserId,
  115. ): int {
  116. $this->insertRow('manual_blocks', [
  117. 'kind' => ManualBlock::KIND_SUBNET,
  118. 'ip_bin' => null,
  119. 'network_bin' => $cidr->network(),
  120. 'prefix_length' => $cidr->prefixLength(),
  121. 'reason' => $reason,
  122. 'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
  123. 'created_by_user_id' => $createdByUserId,
  124. ], ['network_bin' => ParameterType::LARGE_OBJECT]);
  125. return (int) $this->connection()->lastInsertId();
  126. }
  127. public function delete(int $id): void
  128. {
  129. $this->connection()->executeStatement('DELETE FROM manual_blocks WHERE id = :id', ['id' => $id]);
  130. }
  131. /**
  132. * IDs of blocks whose `expires_at` has passed. Used by a future cleanup
  133. * job; the caller decides whether to delete or just flag.
  134. *
  135. * @return list<int>
  136. */
  137. public function findExpired(DateTimeImmutable $now): array
  138. {
  139. /** @var list<array<string, mixed>> $rows */
  140. $rows = $this->connection()->fetchAllAssociative(
  141. 'SELECT id FROM manual_blocks WHERE expires_at IS NOT NULL AND expires_at < :now',
  142. ['now' => $now->format('Y-m-d H:i:s')]
  143. );
  144. return array_map(static fn (array $r): int => (int) $r['id'], $rows);
  145. }
  146. /**
  147. * @return list<ManualBlock>
  148. */
  149. public function listSubnets(): array
  150. {
  151. /** @var list<array<string, mixed>> $rows */
  152. $rows = $this->connection()->fetchAllAssociative(
  153. 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
  154. . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
  155. ['kind' => ManualBlock::KIND_SUBNET]
  156. );
  157. return array_map(self::hydrate(...), $rows);
  158. }
  159. /**
  160. * @return list<ManualBlock>
  161. */
  162. public function listIps(): array
  163. {
  164. /** @var list<array<string, mixed>> $rows */
  165. $rows = $this->connection()->fetchAllAssociative(
  166. 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
  167. . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
  168. ['kind' => ManualBlock::KIND_IP]
  169. );
  170. return array_map(self::hydrate(...), $rows);
  171. }
  172. /**
  173. * @param array<string, mixed> $row
  174. */
  175. private static function hydrate(array $row): ManualBlock
  176. {
  177. $tz = new DateTimeZone('UTC');
  178. $createdAt = isset($row['created_at']) && $row['created_at'] !== null
  179. ? new DateTimeImmutable((string) $row['created_at'], $tz)
  180. : new DateTimeImmutable('now', $tz);
  181. $expiresAt = isset($row['expires_at']) && $row['expires_at'] !== null
  182. ? new DateTimeImmutable((string) $row['expires_at'], $tz)
  183. : null;
  184. $kind = (string) $row['kind'];
  185. $ip = null;
  186. $cidr = null;
  187. if ($kind === ManualBlock::KIND_IP && $row['ip_bin'] !== null) {
  188. $ip = IpAddress::fromBinary((string) $row['ip_bin']);
  189. } elseif ($kind === ManualBlock::KIND_SUBNET && $row['network_bin'] !== null) {
  190. $cidr = Cidr::fromBinary((string) $row['network_bin'], (int) $row['prefix_length']);
  191. }
  192. return new ManualBlock(
  193. id: (int) $row['id'],
  194. kind: $kind,
  195. ip: $ip,
  196. cidr: $cidr,
  197. reason: $row['reason'] !== null ? (string) $row['reason'] : null,
  198. expiresAt: $expiresAt,
  199. createdAt: $createdAt,
  200. createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
  201. );
  202. }
  203. }