6cbc5d79d4
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 (push) Has been cancelled
Wires the existing RepositoryExportDestination/RepositoryImportSource (see lib/UserMigration/) into user_migration's own UserMigrationService, resolved on demand through a new UserMigrationServiceLocator since the app is an optional dependency (PLAN.md 3.8) -- without it, only this feature is unavailable, the full backup still works. New: UserBackupService::backupUser() (occ backup:backup:user) streams a user_migration export straight into the repository, under the same shared location lock as a regular backup. UserRestoreService::restoreUser() (occ backup:restore:user) never imports over a live account: --mode=replace safety-exports the current state first, then deletes and recreates the account from the backup under the same uid; --mode=as-backup restores into a new "<uid>-bak" account instead, since Nextcloud cannot rename a uid. The catalog now also tracks user export manifests (Repository::recordUserExport/ listUserExports/forgetUserExport/isRecordedUserExport), rollback-protected like a snapshot: listUserExports() verifies each one is still present on the location, and restore only ever reads a manifest the verified catalog actually recorded, never an arbitrary caller-supplied path. prune()'s existing raw users/ scan for blob retention is unchanged. A new short exclusive "catalog" lock (PruneService::catalogLockKey()) serializes the catalog write itself, so two concurrent per-user backups (or one alongside a full backup) can no longer race to write the same next generation and silently drop one of them -- the broader per-target shared lock only ever protected against a concurrent cleanup, not against other shared holders. Two real bugs found by testing this against a live Nextcloud instance, not by unit tests (RepositoryExportDestination/RepositoryImportSource implement OCP interfaces that plain PHPUnit can't load, see tests/Unit/Repository/UserExportTest.php's docblock; the new tests/integration/user-roundtrip.sh covers the real round trip instead): - RepositoryImportSource::copyToFolder() double-closed the stream it handed to putContent()/newFile() (both already close it themselves), which throws a TypeError on PHP 8 and broke every restored file with content. - A replace-mode restore failed on NotPermittedException: IRootFolder::getUserFolder() caches a Folder per uid for the process lifetime, so after delete+recreate of the same uid the import still got the deleted account's (now storage-less) folder. Fixed by dispatching FilesystemTornDownEvent, which Root already listens for to clear exactly that cache -- no internal OC\ classes needed. Local review (CodeRabbit + Codex) against this diff found three more real issues, all fixed: the catalog write race above, a failed as-backup import leaving a broken stub account behind (now deleted on failure), and an export stream leak on a read/backend failure in RepositoryExportDestination::copyFolder() (now closed in a finally). Deliberately left out: wiring per-user export into the automatic backup schedule (on-demand via occ only, for now) and retention/pruning of old user exports -- both open design questions, not yet decided. The lease covering a single export/import still has a fixed TTL (3600s) with no mid-call refresh, since UserMigrationService exposes no progress; a resumable, step-based per-user backup (like the main BackupService run) would remove that cap but is a bigger change than this pass.
99 lines
3.1 KiB
PHP
99 lines
3.1 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,
|
|
) {
|
|
$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)];
|
|
}
|
|
|
|
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->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 static function norm(string $path): string {
|
|
return trim($path, '/');
|
|
}
|
|
}
|