Files
nextcloud-ng_backup/lib/UserMigration/RepositoryExportDestination.php
dillard 70965dd4bd
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
Keep leases alive during long restores and exports; PR review fixes
Full restore, raw directory restore, per-user export and the safety export
held a fixed-TTL lease on the location that nothing refreshed. A restore or
export running longer than that TTL let a cleanup take over and delete data
still in use. They now refresh the lease while they work (per blob, per row
batch, per written chunk), throttled to once a minute.

Also:
- backup:prune still applies snapshot retention when user-export retention
  fails for a location.
- The recovery kit carries the deletion delay, so a disaster recovery keeps
  the original protection instead of falling back to 7 days. Older kits
  still import with the default.
- CI checkouts no longer persist the token in the git config.
- recoveryKit() has its real return type; the stale baseline entry is gone.
2026-10-06 22:38:47 +02:00

109 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\UserMigration;
use OCA\NgBackup\Repository\Repository;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\UserMigrationException;
/**
* Export destination for user_migration that writes straight into the encrypted repository:
* no zip, no copy in the user's folder. Every exported path becomes a blob stream; the list
* of paths is stored as one encrypted manifest (users/<uid>/<exportId>) when close() is called.
*/
final class RepositoryExportDestination implements IExportDestination {
/** @var array<string, array{t:string, b?:list<string>, s?:int}> path => entry */
private array $entries = [];
private array $versions = [];
private array $stats = ['newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
private string $exportId;
public function __construct(
private Repository $repo,
private string $uid,
/** Called while data is written (per chunk), to keep the location lease alive. */
private ?\Closure $heartbeat = null,
) {
$this->exportId = gmdate('Ymd\THis\Z') . '-' . bin2hex(random_bytes(4));
}
public function addFileContents(string $path, string $content): void {
$writer = $this->repo->blobWriter($this->stats);
$writer->write($content);
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => strlen($content)];
$this->beat();
}
public function addFileAsStream(string $path, $stream): void {
$writer = $this->repo->blobWriter($this->stats);
while (!feof($stream)) {
$chunk = fread($stream, 1048576);
if ($chunk === false) {
throw new UserMigrationException('Read error while exporting ' . $path);
}
$writer->write($chunk);
$this->beat();
}
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => $writer->bytes()];
}
public function copyFolder(Folder $folder, string $destinationPath, ?callable $nodeFilter = null): void {
$base = self::norm($destinationPath);
$this->entries[$base] = ['t' => 'd'];
foreach ($folder->getDirectoryListing() as $node) {
if ($nodeFilter !== null && !$nodeFilter($node)) {
continue;
}
$target = $base . '/' . $node->getName();
if ($node instanceof Folder) {
$this->copyFolder($node, $target, $nodeFilter);
} elseif ($node instanceof File) {
$stream = $node->fopen('rb');
if ($stream === false) {
throw new \RuntimeException('Cannot read ' . $node->getPath());
}
try {
$this->addFileAsStream($target, $stream);
} finally {
fclose($stream);
}
}
}
}
public function setMigratorVersions(array $versions): void {
$this->versions = $versions;
}
public function close(): void {
$this->repo->flushPacks();
$manifest = ['uid' => $this->uid, 'time' => gmdate('c'), 'versions' => $this->versions, 'entries' => $this->entries];
$this->repo->putObject($this->manifestPath(), json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
public function manifestPath(): string {
return 'users/' . $this->uid . '/' . $this->exportId;
}
public function stats(): array {
return $this->stats;
}
private function beat(): void {
if ($this->heartbeat !== null) {
($this->heartbeat)();
}
}
private static function norm(string $path): string {
return trim($path, '/');
}
}