path = $path; $this->index = $path . '.idx'; } public function count(): int { clearstatcache(true, $this->index); return is_file($this->index) ? intdiv((int) filesize($this->index), 8) : 0; } /** Append entries; their "i" is assigned here. @param array[] $entries {g, r, s, m, h} */ public function add(array $entries): void { if ($entries === []) { return; } [$count, $offset] = $this->repair(); $lines = ''; $offsets = ''; foreach ($entries as $e) { $line = json_encode(['i' => $count++, 'g' => $e['g'], 'r' => $e['r'], 's' => (int) $e['s'], 'm' => (int) $e['m'], 'h' => $e['h']], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; $offsets .= pack('J', $offset + strlen($lines)); $lines .= $line; } Fs::ensure_dir(dirname($this->path)); if (file_put_contents($this->path, $lines, FILE_APPEND) !== strlen($lines) || file_put_contents($this->index, $offsets, FILE_APPEND) !== strlen($offsets)) { throw new RelayError('files.write_failed', esc_html__('Could not write the file list. The disk may be full.', 'siterelay')); } } public function get(int $i): ?array { return $this->entries([$i])[$i]; } /** * Several entries by number, the list's two files opened once for all of them (a batch of files asks for * hundreds). Keys are the numbers asked for, in that order; a number that isn't listed gives null. * * @param int[] $ids * @return array */ public function entries(array $ids): array { $out = []; $count = $ids === [] ? 0 : $this->count(); $idx = null; $h = null; try { foreach ($ids as $i) { $out[$i] = null; if ($i < 0 || $i >= $count) { continue; } $idx = $idx ?? Fs::open($this->index, 'rb'); $h = $h ?? Fs::open($this->path, 'rb'); fseek($idx, $i * 8); fseek($h, (int) unpack('J', (string) Fs::read($idx, 8))[1]); $entry = json_decode((string) fgets($h), true); $out[$i] = is_array($entry) ? $entry : null; } } finally { if ($idx !== null) { Fs::close($idx); } if ($h !== null) { Fs::close($h); } } return $out; } /** @return \Generator index => entry, from $from on */ public function each(int $from = 0): \Generator { $count = $this->count(); if ($from < 0 || $from >= $count) { return; } $h = Fs::open($this->path, 'rb'); fseek($h, $this->offset($from)); try { for ($i = $from; $i < $count; $i++) { $entry = json_decode((string) fgets($h), true); if (is_array($entry)) { yield $i => $entry; } } } finally { Fs::close($h); } } /** Raw lines from entry $from on, at most $max_bytes (at least one line). @return array{lines: string, next: int} */ public function read(int $from, int $max_bytes): array { $lines = ''; $next = $from; foreach ($this->each($from) as $i => $entry) { $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; if ($lines !== '' && strlen($lines) + strlen($line) > $max_bytes) { break; } $lines .= $line; $next = $i + 1; } return ['lines' => $lines, 'next' => $next]; } /** * Append lines another site sent (see read()). Lines this list already has (a resent page) are skipped; * each new line must carry the next index and be a valid entry for $parts that is not one of $protected. * Returns the new count. */ public function import(string $lines, array $parts, array $protected): int { $count = $this->count(); $entries = []; foreach (explode("\n", rtrim($lines, "\n")) as $raw) { if ($raw === '') { continue; } $e = json_decode($raw, true); if (!is_array($e) || !is_int($e['i'] ?? null)) { throw self::bad(esc_html(substr($raw, 0, 120))); } if ($e['i'] < $count + count($entries)) { continue; } if ($e['i'] !== $count + count($entries) || !self::valid_entry($e) || !FileParts::allows($parts, $e['g'], $e['r']) || RelPath::is_protected($e['g'], $e['r'], $protected)) { throw self::bad(esc_html(substr($raw, 0, 120))); } $entries[] = $e; } $this->add($entries); return $this->count(); } /** Forget entries from $count on (a request that died after writing them). */ public function truncate(int $count): void { if ($count >= $this->count()) { return; } if ($count <= 0) { $this->delete(); return; } self::cut($this->path, $this->offset($count)); self::cut($this->index, $count * 8); } public function delete(): void { wp_delete_file($this->path); wp_delete_file($this->index); } public static function valid_entry(array $e): bool { return is_string($e['g'] ?? null) && in_array($e['g'], RelPath::GROUPS, true) && is_string($e['r'] ?? null) && RelPath::valid($e['r']) && is_int($e['s'] ?? null) && $e['s'] >= 0 && is_int($e['m'] ?? null) && is_string($e['h'] ?? null) && preg_match('/^[0-9a-f]{40}$/', $e['h']); } private function offset(int $i): int { $idx = Fs::open($this->index, 'rb'); fseek($idx, $i * 8); $offset = unpack('J', (string) Fs::read($idx, 8))[1]; Fs::close($idx); return (int) $offset; } /** * Drop what a request that died mid-write left behind — lines without an index entry, a partial index * entry — so new entries follow the last complete one. @return array{0: int, 1: int} count, end of the lines */ private function repair(): array { $count = $this->count(); clearstatcache(true, $this->index); if (is_file($this->index) && filesize($this->index) !== $count * 8) { self::cut($this->index, $count * 8); } $end = 0; if ($count > 0) { $h = Fs::open($this->path, 'rb'); $last = $this->offset($count - 1); fseek($h, $last); $end = $last + strlen((string) fgets($h)); Fs::close($h); } clearstatcache(true, $this->path); if (is_file($this->path) && filesize($this->path) > $end) { self::cut($this->path, $end); } return [$count, $end]; } private static function cut(string $file, int $size): void { $h = Fs::open($file, 'r+b'); ftruncate($h, $size); Fs::close($h); clearstatcache(true, $file); } private static function bad(string $line): RelayError { /* translators: %s: start of the rejected line */ return new RelayError('files.bad_manifest', esc_html(sprintf(__('The other site sent a file list SiteRelay will not use: %s', 'siterelay'), $line))); } }