953312bf44
PHP reads a file 8 KiB at a time by default. On a disk where every request waits, such as a network or cloud volume, that made reading five times slower: on the test server 5 MiB/s instead of 27 MiB/s. Without PHP's read buffer every read of a blob is one request to the disk. The same for packs on a local location.
156 lines
4.2 KiB
PHP
156 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\NgBackup\Backend;
|
|
|
|
final class LocalBackend implements IBackend {
|
|
use GetsOneByOne;
|
|
|
|
public function __construct(
|
|
private string $root,
|
|
) {
|
|
$this->root = rtrim($root, '/');
|
|
if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) {
|
|
throw new BackendException('Cannot create ' . $this->root);
|
|
}
|
|
}
|
|
|
|
public function put(string $path, $stream): void {
|
|
$tmp = null;
|
|
$out = false;
|
|
$done = false;
|
|
try {
|
|
$target = $this->abs($path);
|
|
$dir = dirname($target);
|
|
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
throw new BackendException('Cannot create ' . $dir);
|
|
}
|
|
// Write to a temporary name and rename, so a crash never leaves a partial object.
|
|
$tmp = $target . '.part-' . bin2hex(random_bytes(4));
|
|
$out = fopen($tmp, 'wb');
|
|
if ($out === false) {
|
|
throw new BackendException('Cannot write ' . $path);
|
|
}
|
|
$expected = StorageBackend::remaining($stream);
|
|
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
|
|
throw new BackendException('Write failed for ' . $path);
|
|
}
|
|
fclose($out);
|
|
$out = false;
|
|
clearstatcache(true, $tmp);
|
|
if ($expected !== null && filesize($tmp) !== $expected) {
|
|
throw new BackendException('Incomplete write for ' . $path);
|
|
}
|
|
if (!rename($tmp, $target)) {
|
|
throw new BackendException('Cannot finalise ' . $path);
|
|
}
|
|
$done = true;
|
|
} finally {
|
|
if ($out !== false) {
|
|
fclose($out);
|
|
}
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
if (!$done && $tmp !== null) {
|
|
@unlink($tmp);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function get(string $path) {
|
|
$fh = @fopen($this->abs($path), 'rb');
|
|
if ($fh === false) {
|
|
throw new BackendException('Not found: ' . $path);
|
|
}
|
|
// Packs are read in large pieces: one request each, not 8 KiB at a time (see Repository::openForReading()).
|
|
stream_set_read_buffer($fh, 0);
|
|
stream_set_chunk_size($fh, 4 * 1048576);
|
|
return $fh;
|
|
}
|
|
|
|
public function getRange(string $path, int $offset, int $length): string {
|
|
$fh = $this->get($path);
|
|
try {
|
|
if (fseek($fh, $offset) !== 0) {
|
|
throw new BackendException('Seek failed in ' . $path);
|
|
}
|
|
$data = '';
|
|
while (strlen($data) < $length && !feof($fh)) {
|
|
$data .= (string)fread($fh, $length - strlen($data));
|
|
}
|
|
if (strlen($data) !== $length) {
|
|
throw new BackendException('Short read in ' . $path);
|
|
}
|
|
return $data;
|
|
} finally {
|
|
fclose($fh);
|
|
}
|
|
}
|
|
|
|
public function exists(string $path): bool {
|
|
return is_file($this->abs($path));
|
|
}
|
|
|
|
public function list(string $prefix): array {
|
|
$base = $this->abs($prefix);
|
|
if (!is_dir($base)) {
|
|
return [];
|
|
}
|
|
$result = [];
|
|
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
|
|
foreach ($it as $file) {
|
|
if ($file->isFile() && !StorageBackend::isTemporary($file->getFilename())) {
|
|
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
|
|
}
|
|
}
|
|
sort($result);
|
|
return $result;
|
|
}
|
|
|
|
public function listFiles(string $dir): array {
|
|
$base = $this->abs($dir);
|
|
if (!is_dir($base)) {
|
|
return [];
|
|
}
|
|
$result = [];
|
|
$names = scandir($base);
|
|
if ($names === false) {
|
|
throw new BackendException('Cannot list ' . $dir);
|
|
}
|
|
foreach ($names as $name) {
|
|
if ($name !== '.' && $name !== '..' && !StorageBackend::isTemporary($name)) {
|
|
$result[] = trim($dir, '/') . '/' . $name;
|
|
}
|
|
}
|
|
sort($result);
|
|
return $result;
|
|
}
|
|
|
|
public function delete(string $path): void {
|
|
@unlink($this->abs($path));
|
|
}
|
|
|
|
public function move(string $from, string $to): void {
|
|
$target = $this->abs($to);
|
|
$dir = dirname($target);
|
|
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
throw new BackendException('Cannot create ' . $dir);
|
|
}
|
|
if (!rename($this->abs($from), $target)) {
|
|
throw new BackendException("Cannot move $from to $to");
|
|
}
|
|
}
|
|
|
|
private function abs(string $path): string {
|
|
if (str_contains($path, '..') || str_starts_with($path, '/')) {
|
|
throw new BackendException('Invalid path: ' . $path);
|
|
}
|
|
return $this->root . '/' . $path;
|
|
}
|
|
}
|