$failures when the recent failures were */ public static function wait(array $failures, int $now): int { $times = self::recent($failures, $now); sort($times); $until = 0; for ($i = self::MAX_FAILURES - 1; $i < count($times); $i++) { if ($times[$i] - $times[$i - self::MAX_FAILURES + 1] < self::WINDOW_SECONDS) { $until = $times[$i] + self::WINDOW_SECONDS; } } return max(0, $until - $now); } /** Seconds until `$address` may try again, 0 when it may now. */ public function waitFor(string $address): int { $path = $this->path($address); $raw = is_file($path) ? file_get_contents($path) : false; return self::wait($raw === false ? [] : self::decode($raw), $this->now()); } /** Counts a failed attempt of `$address` and answers the wait that follows from it. */ public function fail(string $address): int { if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { throw new RuntimeException('Cannot create the directory of failed sign-ins'); } $path = $this->path($address); $handle = fopen($path, 'c+'); if ($handle === false) { throw new RuntimeException('Cannot count a failed sign-in'); } try { chmod($path, AtomicFile::HIDDEN); flock($handle, LOCK_EX); $now = $this->now(); $failures = self::recent(self::decode((string) stream_get_contents($handle)), $now); $failures[] = $now; ftruncate($handle, 0); rewind($handle); fwrite($handle, (string) json_encode(['failures' => $failures])); fflush($handle); return self::wait($failures, $now); } finally { flock($handle, LOCK_UN); fclose($handle); } } /** Removes the files of addresses whose failures are all too old to matter. */ public function prune(): int { $removed = 0; foreach (glob($this->dir . '/*.json') ?: [] as $path) { if ((int) filemtime($path) < $this->now() - 2 * self::WINDOW_SECONDS && unlink($path)) { $removed++; } } return $removed; } /** * The failures that can still matter: five of them lie within one window, and the lock lasts one * window after the last, so nothing older than two windows can. * * @param list $failures * @return list */ private static function recent(array $failures, int $now): array { return array_values(array_filter($failures, static fn(int $at): bool => $at > $now - 2 * self::WINDOW_SECONDS)); } /** @return list */ private static function decode(string $raw): array { $data = json_decode($raw, true); $failures = is_array($data) && is_array($data['failures'] ?? null) ? $data['failures'] : []; return array_values(array_map('intval', array_filter($failures, 'is_int'))); } private function path(string $address): string { return $this->dir . '/' . hash('sha256', $address) . '.json'; } private function now(): int { return $this->clock === null ? time() : ($this->clock)(); } }