targets->list() as $t) { $targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(), 'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(), 'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)]; } $shown = $this->runs->findShown($allRuns ? null : time() - \OCA\NgBackup\Command\Status::RECENT_DAYS * 86400); $runs = array_map(fn (Run $r) => $this->runJson($r), $shown); return new JSONResponse([ 'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(), 'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(), 'slots' => $this->keys->isInitialized() ? $this->keys->slots() : [], 'maxSlots' => \OCA\NgBackup\Service\KeyService::MAX_SLOTS, 'deleteDelayDays' => $this->keys->isInitialized() ? $this->keys->deleteDelayDays() : \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY], 'statement' => KeyConfirm::STATEMENT, 'confirmationPhrase' => KeyConfirm::CONFIRMATION, 'targets' => $targets, 'runs' => $runs, 'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown), 'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS, 'schedule' => $this->schedule->get()['time'], 'scheduleTimezone' => $this->schedule->get()['timezone'], 'cron' => $this->cron->check(), ]); } /** Seconds of backup work one tick() does: well within a normal web request. */ public const TICK_SECONDS = 10.0; /** * Move running backups forward from the open admin page, when background jobs do not run on a * working system cron (#13). The page calls this while a backup runs, so "Back up now" works * without cron as long as the page stays open; scheduled backups still need cron. With a * working system cron this does nothing: the background job does the work. */ public function tick(): JSONResponse { if ($this->cron->check()['ok']) { return new JSONResponse(['stepped' => 0]); } $deadline = microtime(true) + self::TICK_SECONDS; $stepped = 0; foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) { if (microtime(true) >= $deadline) { break; } // step() returns at once when the background job (or another tab) holds the run. $this->backups->step($run, $deadline); $stepped++; } return new JSONResponse(['stepped' => $stepped]); } #[PasswordConfirmationRequired] public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse { try { $this->keys->initialize($passphrase, $deleteDelayDays, $label); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]); } /** * Plain download link (no CSRF token on a navigation), therefore guarded by a recent password * confirmation instead, logged to the audit log and announced to all administrators. The kit * only holds the passphrase-wrapped key: useless without the passphrase. */ #[NoCSRFRequired] #[PasswordConfirmationRequired] public function downloadKit(): DataDownloadResponse|JSONResponse { if (!$this->keys->isInitialized()) { return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST); } $this->alerts->securityEvent('kit_downloaded'); $json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json'); } /** The admin must tick the statement, send the exact phrase and the code from the kit file. */ #[PasswordConfirmationRequired] public function confirmKit(bool $accepted, string $phrase, string $code = ''): JSONResponse { if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) { return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST); } if (!$this->keys->checkKitCode($code)) { return new JSONResponse(['error' => 'The confirmation code does not match the current recovery kit'], Http::STATUS_BAD_REQUEST); } $this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown'); return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]); } #[PasswordConfirmationRequired] public function addSlot(string $passphrase, string $label): JSONResponse { try { return new JSONResponse(['slot' => $this->slots->add($passphrase, $label)]); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } #[PasswordConfirmationRequired] public function replaceSlot(int $slot, string $passphrase, ?string $label = null): JSONResponse { try { $this->slots->replace($slot, $passphrase, $label); return new JSONResponse([]); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } #[PasswordConfirmationRequired] public function removeSlot(int $slot): JSONResponse { try { $this->slots->remove($slot); return new JSONResponse([]); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } public function trash(int $targetId): JSONResponse { try { $delay = $this->keys->deleteDelayDays() * 86400; $out = []; foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) { $out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'], 'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay]; } return new JSONResponse($out); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } #[PasswordConfirmationRequired] public function untrash(int $targetId, string $snapshotId): JSONResponse { try { $this->prune->untrash($this->targets->get((string)$targetId), $snapshotId); return new JSONResponse([]); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } public function backends(): JSONResponse { return new JSONResponse($this->targets->availableBackends()); } #[PasswordConfirmationRequired] public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse { try { $r = $this->targets->add($name, $backend, $auth, $options, $path); if ($maxGb !== null && $maxGb > 0) { $this->targets->setMaxBytes($r['target'], (int)round($maxGb * 1073741824)); } } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]); } /** The stored settings for the edit form; password values are left out (null). */ #[PasswordConfirmationRequired] public function targetSettings(int $id): JSONResponse { $t = $this->targets->get((string)$id); return new JSONResponse(['name' => $t->getName(), 'backend' => $t->getBackend(), 'auth' => $t->getAuth(), 'options' => $this->targets->editableOptions($t), 'path' => $t->getBasePath()]); } /** Options with a null value keep their stored value. */ #[PasswordConfirmationRequired] public function updateTarget(int $id, string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse { try { $t = $this->targets->get((string)$id); $before = $t->getName(); $t = $this->targets->update($t, $name, $backend, $auth, $options, $path); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } $this->alerts->securityEvent('target_changed', ['target' => $before]); return new JSONResponse(['id' => $t->getId()]); } /** Size limit in GiB; null or 0 = unlimited. */ #[PasswordConfirmationRequired] public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse { $t = $this->targets->setMaxBytes($this->targets->get((string)$id), ($maxGb !== null && $maxGb > 0) ? (int)round($maxGb * 1073741824) : null); return new JSONResponse(['maxBytes' => $t->getMaxBytes()]); } #[PasswordConfirmationRequired] public function removeTarget(int $id): JSONResponse { $target = $this->targets->get((string)$id); $this->targets->remove($target); $this->alerts->securityEvent('target_removed', ['target' => $target->getName()]); return new JSONResponse([]); } public function testTarget(int $id): JSONResponse { try { $repo = $this->targets->repository($this->targets->get((string)$id)); return new JSONResponse(['ok' => true, 'snapshots' => count($repo->listSnapshots())]); } catch (\Throwable $e) { return new JSONResponse(['ok' => false, 'error' => $e->getMessage()]); } } public function startBackup(int $targetId, string $label = ''): JSONResponse { try { $run = $this->backups->start($this->targets->get((string)$targetId), $label); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse($this->runJson($run)); } /** @param string $timezone the administrator's zone (the browser's); empty: keep the current one */ public function setSchedule(string $time, string $timezone = ''): JSONResponse { try { $this->schedule->set($time, $timezone !== '' ? $timezone : $this->schedule->get()['timezone']); } catch (\InvalidArgumentException $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } return new JSONResponse(['schedule' => $time, 'scheduleTimezone' => $this->schedule->get()['timezone']]); } public function snapshots(int $targetId): JSONResponse { $out = []; foreach ($this->snapshots->findForTarget($targetId) as $s) { $out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(), 'files' => $s->getFiles(), 'bytes' => $s->getBytes()]; } return new JSONResponse($out); } public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse { try { return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path)); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } /** Queue a restore of user files; it runs as a background job and shows up in the runs. */ #[PasswordConfirmationRequired] public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse { if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true) || !\OCA\NgBackup\Repository\Repository::isSafePath($path) || !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) { return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST); } try { $target = $this->targets->get((string)$targetId); } catch (\Throwable) { return new JSONResponse(['error' => 'No such location'], Http::STATUS_NOT_FOUND); } $run = new Run(); $run->setTargetId($target->getId()); $run->setKind('restore'); $run->setStatus(Run::RUNNING); $run->setPhase('queued'); $run->setState(json_encode(['snapshot' => $snapshotId, 'path' => $path, 'mode' => $mode, 'by' => $this->session->getUser()?->getUID()], JSON_THROW_ON_ERROR)); $run->setSnapshotId($snapshotId); $run->setStartedAt(time()); $run->setUpdatedAt(time()); try { $run = $this->prune->underStartLock($target, fn () => $this->runs->insert($run)); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_CONFLICT); } $this->jobs->add(RestoreJob::class, ['run' => $run->getId()]); return new JSONResponse($this->runJson($run)); } private function runJson(Run $r): array { $snapshot = null; if ($r->getSnapshotId() !== null) { try { $s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId()); $snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()]; } catch (\Throwable) { // Snapshot already pruned or trashed: the run keeps its id, just not the date. } } return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(), 'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(), 'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(), 'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r), 'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null]; } }