repo = $repo; $this->settings = $settings; $this->jobs = $jobs; } public static function path(array $row): string { return Paths::backups_dir() . '/' . $row['file']; } public function get(string $id): array { $row = $this->repo->find($id); if ($row === null) { throw new RelayError('backup.not_found', esc_html__('Backup not found.', 'siterelay')); } return $row; } public function get_ready(string $id): array { $row = $this->get($id); if ($row['status'] !== BackupRepository::READY || $row['file'] === '' || !is_file(self::path($row))) { throw new RelayError('backup.not_ready', esc_html__('This backup is not available (it is still being created, or its file is missing).', 'siterelay')); } return $row; } public function delete(string $id): void { $row = $this->get($id); if ($row['status'] !== BackupRepository::READY && $row['status'] !== BackupRepository::FAILED) { throw new RelayError('backup.not_ready', esc_html__('This backup is still being created or uploaded.', 'siterelay')); } if ($this->in_use($id)) { throw new RelayError('backup.in_use', esc_html__('This backup is being restored right now.', 'siterelay')); } $this->remove($row); } /** @return array{path: string, filename: string} */ public function download_info(string $id): array { $row = $this->get_ready($id); $url = $row['source_url'] !== '' ? $row['source_url'] : home_url(); // A subsite in a folder of its network is named with that folder: example.org-shop-… $folder = trim((string) wp_parse_url($url, PHP_URL_PATH), '/'); $name = (string) wp_parse_url($url, PHP_URL_HOST) . ($folder !== '' ? '-' . str_replace('/', '-', $folder) : ''); $stamp = wp_date('Y-m-d-Hi', (int) strtotime($row['created_at'] . ' UTC')); // the site's time zone return ['path' => self::path($row), 'filename' => Stream::file_name($name . '-' . $stamp) . '-siterelay-backup.zip']; } /** * Keep the newest `backups_limit` ready backups (by creation time); never one that is being restored. * $added (the backup just created or uploaded) always stays, even when it was made earlier than the others. */ public function retain(?string $added = null): void { $keep = max(1, (int) $this->settings->get('backups_limit')); $rows = $this->repo->ready(); $first = array_values(array_filter($rows, static fn ($r) => $r['id'] === $added)); $rest = array_values(array_filter($rows, static fn ($r) => $r['id'] !== $added)); foreach (array_slice(array_merge($first, $rest), $keep) as $row) { if (!$this->in_use($row['id'])) { $this->remove($row); } } } /** What the UI shows for a backup, derived from its manifest. */ public static function summary(array $manifest): array { $files = []; foreach ((array) ($manifest['files'] ?? []) as $group => $f) { $files[(string) $group] = ['count' => (int) ($f['count'] ?? 0), 'bytes' => (int) ($f['bytes'] ?? 0)]; } $site = $manifest['site']; // A backup made before 1.0 (format 1) held every part; only a format 2 backup can leave parts out. $contents = $manifest['format'] === 1 ? [] : ['contents' => \Relay\Archive\Manifest::contents($manifest)]; return $contents + [ 'format' => (int) $manifest['format'], 'compressed' => $manifest['format'] === 1 || !empty($manifest['compressed']), 'site_name' => sanitize_text_field(\Relay\Support\SiteInfo::plain_name((string) ($site['name'] ?? ''))), 'prefix' => (string) $site['prefix'], 'tables' => count($manifest['tables']), 'rows' => array_sum(array_map(static fn ($t) => (int) ($t['rows'] ?? 0), $manifest['tables'])), 'files' => $files, 'skipped' => count((array) ($manifest['skipped'] ?? [])), 'wp' => sanitize_text_field((string) ($site['wp'] ?? '')), 'php' => sanitize_text_field((string) ($site['php'] ?? '')), 'mysql' => sanitize_text_field((string) ($site['mysql'] ?? '')), ]; } /** Upload chunk size: 8 MB, or less when post_max_size is smaller. */ public static function chunk_size(): int { $limit = 8 * MB_IN_BYTES; $post_max = (int) wp_convert_hr_to_bytes((string) ini_get('post_max_size')); return $post_max > 0 ? max(64 * KB_IN_BYTES, min($limit, $post_max - 64 * KB_IN_BYTES)) : $limit; } /** @return array{upload_id: string, chunk_size: int} */ public function upload_start(string $filename, int $size): array { if (strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)) !== 'zip') { throw new RelayError('upload.not_zip', esc_html__('Choose a backup .zip file.', 'siterelay')); } if ($size <= 0) { throw new RelayError('upload.size_mismatch', esc_html__('The file is empty.', 'siterelay')); } $dir = Paths::backups_dir(); $free = Fs::free_space($dir); if ($free !== false && $free < $size * 1.1) { /* translators: %s: amount of disk space, e.g. "1.2 GB" */ throw new RelayError('backup.disk_space', esc_html(sprintf(__('Not enough disk space for this backup. About %s is needed.', 'siterelay'), size_format((int) ($size * 1.1))))); } $row = $this->repo->create(['origin' => 'imported', 'status' => BackupRepository::UPLOADING, 'size' => $size]); Fs::put(self::upload_path($row['id']), ''); return ['upload_id' => $row['id'], 'chunk_size' => self::chunk_size()]; } /** Append a chunk written at $offset. Returns how many bytes the server now has. */ public function upload_append(string $id, int $offset, string $body): int { $row = $this->uploading($id); $path = self::upload_path($id); clearstatcache(true, $path); $length = is_file($path) ? (int) filesize($path) : 0; if ($offset !== $length) { throw new RelayError('upload.offset_mismatch', esc_html__('The upload got out of step; it continues from where the server is.', 'siterelay'), '', false, ['received' => $length]); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- context is data returned as JSON; the message is escaped } if ($length + strlen($body) > $row['size']) { throw new RelayError('upload.size_mismatch', esc_html__('The upload is larger than the file that was announced.', 'siterelay')); } if ($body !== '' && file_put_contents($path, $body, FILE_APPEND) !== strlen($body)) { $h = Fs::open($path, 'r+'); ftruncate($h, $length); Fs::close($h); throw new RelayError('backup.write_failed', esc_html__('Could not write to the backup file. The disk may be full.', 'siterelay'), '', true); } return $length + strlen($body); } /** Check the uploaded archive and turn it into a ready backup. A rejected archive is deleted. */ public function upload_complete(string $id): array { $row = $this->uploading($id); $tmp = self::upload_path($id); try { clearstatcache(true, $tmp); if (!is_file($tmp) || (int) filesize($tmp) !== $row['size']) { throw new RelayError('upload.size_mismatch', esc_html__('The upload did not finish. Try again.', 'siterelay')); } $manifest = ArchiveInspector::inspect($tmp, Groups::protected_items(Groups::roots()))['manifest']; $file = Paths::random_name('zip'); if (!Fs::rename($tmp, Paths::backups_dir() . '/' . $file)) { throw new RelayError('backup.write_failed', esc_html__('Could not store the uploaded backup.', 'siterelay')); } $created = strtotime((string) ($manifest['created_at'] ?? '')); $this->repo->update($id, [ 'status' => BackupRepository::READY, 'file' => $file, 'label' => mb_substr(sanitize_text_field((string) ($manifest['label'] ?? '')), 0, 190), 'source_url' => esc_url_raw((string) $manifest['site']['home_url']), 'summary' => self::summary($manifest), 'created_at' => $created ? gmdate('Y-m-d H:i:s', $created) : $row['added_at'], ]); } catch (RelayError $e) { wp_delete_file($tmp); $this->repo->delete($id); throw $e; } $this->retain($id); return $this->get($id); } public function upload_abort(string $id): void { $this->remove($this->uploading($id)); } /** Daily: failed rows, uploads idle for a day, and building rows whose job is gone. */ public function sweep(): void { foreach ($this->repo->with_status(BackupRepository::FAILED) as $row) { $this->remove($row); } foreach ($this->repo->with_status(BackupRepository::UPLOADING, gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS)) as $row) { $this->remove($row); } foreach ($this->repo->with_status(BackupRepository::BUILDING) as $row) { $job = $row['job_id'] ? $this->jobs->find((string) $row['job_id']) : null; if ($job === null || $job->is_finished()) { $this->remove($row); } } } private function uploading(string $id): array { $row = $this->get($id); if ($row['status'] !== BackupRepository::UPLOADING) { throw new RelayError('backup.not_found', esc_html__('This upload no longer exists. Start the upload again.', 'siterelay')); } return $row; } private function in_use(string $id): bool { $active = $this->jobs->active(); return $active !== null && $active->type === 'backup_restore' && ($active->settings['backup_id'] ?? '') === $id; } /** Delete a row and every file that belongs to it. */ private function remove(array $row): void { $dir = Paths::backups_dir(); if ($row['file'] !== '') { wp_delete_file($dir . '/' . $row['file']); wp_delete_file($dir . '/' . $row['file'] . '.part'); } if (!empty($row['job_id'])) { wp_delete_file($dir . '/.' . $row['job_id'] . '.cd'); wp_delete_file($dir . '/.' . $row['job_id'] . '.list'); } wp_delete_file(self::upload_path($row['id'])); $this->repo->delete($row['id']); } private static function upload_path(string $id): string { return Paths::backups_dir() . '/' . $id . '.upload'; } }