Phase 0: per-user backup/restore through user_migration (spike E)

RepositoryExportDestination / RepositoryImportSource implement the
public OCP\UserMigration interfaces on top of the repository, so
user_migration's migrators (calendar, contacts, trashbin, account,
files) export straight into the encrypted repository.

Spike E (NC34): export 0.8s, unchanged second export stores nothing;
after deleting the user, import restores account, files (bit-identical),
calendar event and contact. Findings: calendars/address books come back
as 'migrated-*' with new object URIs; importing over an existing user
duplicates contacts and adds a migrated birthday calendar; creating the
user on import needs >128 MB (password_policy word list).
This commit is contained in:
2026-10-01 18:36:57 +02:00
parent ab3f0ea948
commit 0b4246b1b3
4 changed files with 369 additions and 0 deletions
@@ -0,0 +1,94 @@
<?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;
/**
* 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) {
break;
}
$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());
}
$this->addFileAsStream($target, $stream);
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, '/');
}
}
@@ -0,0 +1,120 @@
<?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\Folder;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException;
/** Import source for user_migration that reads a user export from the repository. */
final class RepositoryImportSource implements IImportSource {
private array $manifest;
public function __construct(
private Repository $repo,
string $manifestPath,
) {
$this->manifest = json_decode($repo->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
}
public function getFileContents(string $path): string {
$stream = $this->getFileAsStream($path);
$data = (string)stream_get_contents($stream);
fclose($stream);
return $data;
}
public function getFileAsStream(string $path) {
$entry = $this->entry($path, 'f');
// Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file).
$stream = fopen('php://temp/maxmemory:' . (8 * 1048576), 'w+b');
foreach ($this->repo->readBlobs($entry['b']) as $data) {
fwrite($stream, $data);
}
rewind($stream);
return $stream;
}
public function getFolderListing(string $path): array {
$prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/';
$names = [];
foreach (array_keys($this->manifest['entries']) as $p) {
if ($prefix === '' || str_starts_with($p, $prefix)) {
$rest = substr($p, strlen($prefix));
if ($rest !== '' && !str_contains($rest, '/')) {
$names[] = $rest;
}
}
}
sort($names);
return $names;
}
public function pathExists(string $path): bool {
$p = trim($path, '/');
if (isset($this->manifest['entries'][$p])) {
return true;
}
foreach (array_keys($this->manifest['entries']) as $k) {
if (str_starts_with($k, $p . '/')) {
return true;
}
}
return false;
}
public function copyToFolder(Folder $destination, string $sourcePath): void {
foreach ($this->getFolderListing($sourcePath) as $name) {
$path = trim($sourcePath, '/') . '/' . $name;
$entry = $this->manifest['entries'][$path] ?? ['t' => 'd'];
if ($entry['t'] === 'd') {
$sub = $destination->nodeExists($name) ? $destination->get($name) : $destination->newFolder($name);
if (!$sub instanceof Folder) {
throw new UserMigrationException("$path exists and is not a folder");
}
$this->copyToFolder($sub, $path);
} else {
$stream = $this->getFileAsStream($path);
if ($destination->nodeExists($name)) {
$destination->get($name)->putContent($stream);
} else {
$destination->newFile($name, $stream);
}
if (is_resource($stream)) {
fclose($stream);
}
}
}
}
public function getMigratorVersions(): array {
return $this->manifest['versions'];
}
public function getMigratorVersion(string $migrator): ?int {
return $this->manifest['versions'][$migrator] ?? null;
}
public function getOriginalUid(): string {
return $this->manifest['uid'];
}
public function close(): void {
}
/** @return array{t:string, b:list<string>, s:int} */
private function entry(string $path, string $type): array {
$entry = $this->manifest['entries'][trim($path, '/')] ?? null;
if ($entry === null || $entry['t'] !== $type) {
throw new UserMigrationException('Path not found in backup: ' . $path);
}
return $entry;
}
}