.upload with a .json beside it; an import unpacks a * .gz to .sql. Uploads are deleted when their import ends, and after a day if never used. */ final class ImportUploads { private const ID = '/^[0-9a-f]{32}$/'; public static function dir(): string { return Fs::ensure_private_dir(Paths::export_dir() . '/imports'); } /** @return array{upload_id: string, chunk_size: int} */ public function start(string $filename, int $size): array { $name = strtolower($filename); if (!preg_match('/\.(sql|sql\.gz|gz)$/', $name)) { throw new RelayError('upload.not_sql', esc_html__('Choose a .sql or .sql.gz file, or a SiteRelay backup .zip.', 'siterelay')); } if ($size <= 0) { throw new RelayError('upload.size_mismatch', esc_html__('The file is empty.', 'siterelay')); } $free = Fs::free_space(self::dir()); $need = substr($name, -3) === '.gz' ? $size * 12 : $size * 3; // the file, its unpacked SQL and the tables it becomes if ($free !== false && $free < $need) { /* translators: %s: amount of disk space, e.g. "1.2 GB" */ throw new RelayError('import.disk_space', esc_html(sprintf(__('Not enough disk space to import this file. About %s is needed.', 'siterelay'), size_format($need)))); } $id = bin2hex(random_bytes(16)); Fs::put(self::path($id, 'upload'), ''); // The name is only shown (files are stored by id); sanitize_file_name() would turn "site.sql.gz" into "site.sql_.gz". $display = mb_substr(sanitize_text_field(wp_basename(str_replace('\\', '/', $filename))), 0, 190); $this->write_meta($id, ['id' => $id, 'filename' => $display, 'size' => $size, 'status' => 'uploading', 'gzip' => false, 'created_at' => gmdate('Y-m-d H:i:s')]); return ['upload_id' => $id, 'chunk_size' => self::chunk_size()]; } /** Append a chunk written at $offset. Returns how many bytes the server now has. */ public function append(string $id, int $offset, string $body): int { $meta = $this->meta($id, 'uploading'); $path = self::path($id, 'upload'); 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) > $meta['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('import.write_failed', esc_html__('Could not store the uploaded file. The disk may be full.', 'siterelay'), '', true); } return $length + strlen($body); } /** Check the upload: complete, and gzip or plain text. A rejected file is deleted. */ public function complete(string $id): array { $meta = $this->meta($id, 'uploading'); $path = self::path($id, 'upload'); try { clearstatcache(true, $path); if (!is_file($path) || (int) filesize($path) !== $meta['size']) { throw new RelayError('upload.size_mismatch', esc_html__('The upload did not finish. Try again.', 'siterelay')); } $gzip = Gunzip::is_gzip($path); if (!$gzip) { $h = Fs::open($path, 'rb'); $head = (string) Fs::read($h, 4096); Fs::close($h); if (substr(strtolower($meta['filename']), -3) === '.gz' || strpos($head, "\0") !== false || strpos($head, 'PK') === 0) { throw new RelayError('upload.not_sql', esc_html__('This file is not an SQL dump (plain or gzip).', 'siterelay')); } } } catch (RelayError $e) { $this->delete($id); throw $e; } $meta['status'] = 'ready'; $meta['gzip'] = $gzip; $this->write_meta($id, $meta); return self::to_api($meta); } public function abort(string $id): void { $this->meta($id, 'uploading'); $this->delete($id); } /** @return array{id: string, filename: string, size: int, gzip: bool, upload: string, sql: string} a ready upload and its paths */ public function get_ready(string $id): array { $meta = $this->meta($id, 'ready'); return $meta + ['upload' => self::path($id, 'upload'), 'sql' => $meta['gzip'] ? self::path($id, 'sql') : self::path($id, 'upload')]; } public function delete(string $id): void { if (!preg_match(self::ID, $id)) { return; } foreach (['upload', 'sql', 'json'] as $ext) { wp_delete_file(self::path($id, $ext)); } } /** Daily: uploads older than a day that no running import uses. @param string[] $in_use */ public function sweep(array $in_use = []): void { foreach (glob(self::dir() . '/*.json') ?: [] as $file) { $meta = json_decode((string) file_get_contents($file), true); $id = basename($file, '.json'); if (in_array($id, $in_use, true)) { continue; } if (!is_array($meta) || strtotime((string) ($meta['created_at'] ?? '') . ' UTC') < time() - DAY_IN_SECONDS) { $this->delete($id); } } } public static function to_api(array $meta): array { return ['id' => $meta['id'], 'filename' => $meta['filename'], 'size' => (int) $meta['size'], 'gzip' => (bool) $meta['gzip']]; } private function meta(string $id, string $status): array { $meta = preg_match(self::ID, $id) && is_file(self::path($id, 'json')) ? json_decode((string) file_get_contents(self::path($id, 'json')), true) : null; if (!is_array($meta) || ($meta['status'] ?? '') !== $status) { throw new RelayError('import.not_found', esc_html__('That uploaded file is no longer available. Upload it again.', 'siterelay')); } return $meta; } private function write_meta(string $id, array $meta): void { Fs::put(self::path($id, 'json'), (string) wp_json_encode($meta)); } private static function path(string $id, string $ext): string { return self::dir() . '/' . $id . '.' . $ext; } private 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; } }