Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for importing .wpress files #497

Merged
merged 28 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4b5c9a8
Add support for importing `.wpress` files
danielbachhuber Sep 2, 2024
0f5be5c
Fix linting errors
danielbachhuber Sep 2, 2024
9b8978f
Merge branch 'trunk' into improve/import-wpress-files
danielbachhuber Sep 2, 2024
d4d8f2e
Merge branch 'trunk' into improve/import-wpress-files
danielbachhuber Sep 23, 2024
a6182b6
Update theme and childtheme from wpress package json
sejas Sep 24, 2024
603c91b
Merge branch 'trunk' of github.com:Automattic/studio into improve/imp…
sejas Sep 30, 2024
8e7300d
Remove optional parameter on fd.read
sejas Sep 30, 2024
66dceeb
add returns comment
sejas Sep 30, 2024
8bc06a8
Move chunk size to read as a constant
sejas Sep 30, 2024
2dda7df
use async ensureDir
sejas Sep 30, 2024
34d773c
Use buffer compare
sejas Sep 30, 2024
4a08615
use parse meta file instead of custom method
sejas Sep 30, 2024
e16f250
remove unused offset
sejas Sep 30, 2024
a2aad3c
remove console log
sejas Sep 30, 2024
876bcf2
Refactor file types to use them globally
sejas Oct 1, 2024
952f8d6
create SQL to auto activate plugins that were installed
sejas Oct 1, 2024
19aeaf8
move serializePlugins to its own file
sejas Oct 1, 2024
ce7cbd3
Fix conflict on option already existing to active the plugins
sejas Oct 2, 2024
11dd3e2
remove unnecessary debug line to duplicate sql file
sejas Oct 2, 2024
2514225
check if file exists
sejas Oct 3, 2024
a4eda36
replace empty dir sync for async version
sejas Oct 3, 2024
350bfd3
simplify construction of extractionDirectory
sejas Oct 3, 2024
a1a815f
Merge branch 'trunk' of github.com:Automattic/studio into improve/imp…
sejas Oct 6, 2024
de7aec8
update error message
sejas Oct 6, 2024
4b06370
remove unnecessary function
sejas Oct 6, 2024
2707ddd
add test for serialize plugins function
sejas Oct 6, 2024
c5ee97f
add tests for wpress validator
sejas Oct 6, 2024
ae6e28c
add package.json as required file
sejas Oct 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/content-tab-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ const ImportSite = ( props: { selectedSite: SiteDetails } ) => {
className="hidden"
type="file"
data-testid="backup-file"
accept=".zip,.sql,.tar,.gz"
accept=".zip,.sql,.tar,.gz,.wpress"
onChange={ onFileSelected }
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/site-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ function FormImportComponent( {
className="hidden"
type="file"
data-testid="backup-file"
accept=".zip,.tar,.gz"
accept=".zip,.tar,.gz,.wpress"
onChange={ handleFileChange }
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode
type: 'error',
message: __( 'Failed importing site' ),
detail: __(
'An error occurred while importing the site. Verify the file is a valid Jetpack backup, Local, Playground or .sql database file and try again. If this problem persists, please contact support.'
'An error occurred while importing the site. Verify the file is a valid Jetpack backup, Local, Playground, .wpress, or .sql database file and try again. If this problem persists, please contact support.'
),
buttons: [ __( 'OK' ) ],
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { EventEmitter } from 'events';
import { BackupArchiveInfo } from '../types';
import { BackupHandlerSql } from './backup-handler-sql';
import { BackupHandlerTarGz } from './backup-handler-tar-gz';
import { BackupHandlerWpress } from './backup-handler-wpress';
import { BackupHandlerZip } from './backup-handler-zip';

export interface BackupHandler extends Partial< EventEmitter > {
listFiles( file: BackupArchiveInfo ): Promise< string[] >;
extractFiles( file: BackupArchiveInfo, extractionDirectory: string ): Promise< void >;
Expand Down Expand Up @@ -54,6 +54,8 @@ export class BackupHandlerFactory {
return new BackupHandlerTarGz();
} else if ( this.isSql( file ) ) {
return new BackupHandlerSql();
} else if ( this.isWpress( file ) ) {
return new BackupHandlerWpress();
}
}

Expand All @@ -77,4 +79,8 @@ export class BackupHandlerFactory {
this.sqlExtensions.some( ( ext ) => file.path.endsWith( ext ) )
);
}

private static isWpress( file: BackupArchiveInfo ): boolean {
return file.path.endsWith( '.wpress' );
}
}
219 changes: 219 additions & 0 deletions src/lib/import-export/import/handlers/backup-handler-wpress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import * as fse from 'fs-extra';
import { BackupArchiveInfo } from '../types';
import { BackupHandler } from './backup-handler-factory';

/**
* The .wpress format is a custom archive format used by All-In-One WP Migration.
* It is designed to encapsulate all necessary components of a WordPress site, including the database,
* plugins, themes, uploads, and other wp-content files, into a single file for easy transport and restoration.
*
* The .wpress file is structured as follows:
* 1. Header: Contains metadata about the file, such as the name, size, modification time, and prefix.
* The header is a fixed size of 4377 bytes.
* 2. Data Blocks: The actual content of the files, stored in 512-byte blocks. Each file's data is stored
* sequentially, following its corresponding header.
* 3. End of File Marker: A special marker indicating the end of the archive. This is represented by a
* block of 4377 bytes filled with zeroes.
*
* The .wpress format ensures that all necessary components of a WordPress site are included in the backup,
* making it easy to restore the site to its original state. The format is designed to be efficient and
* easy to parse, allowing for quick extraction and restoration of the site's contents.
*/

const HEADER_SIZE = 4377;
const HEADER_CHUNK_EOF = Buffer.alloc( HEADER_SIZE );

interface Header {
name: string;
size: number;
mTime: string;
prefix: string;
}

/**
* Reads a string from a buffer at a given start and end position.
*
* @param {Buffer} buffer - The buffer to read from.
* @param {number} start - The start position of the string in the buffer.
* @param {number} end - The end position of the string in the buffer.
* @returns
sejas marked this conversation as resolved.
Show resolved Hide resolved
*/
function readFromBuffer( buffer: Buffer, start: number, end: number ): string {
const _buffer = buffer.subarray( start, end );
return _buffer.subarray( 0, _buffer.indexOf( 0x00 ) ).toString();
}

/**
* Reads the header of a .wpress file.
*
* @param {fs.promises.FileHandle} fd - The file handle to read from.
* @returns {Promise<Header | null>} - A promise that resolves to the header or null if the end of the file is reached.
*/
async function readHeader( fd: fs.promises.FileHandle ): Promise< Header | null > {
const headerChunk = Buffer.alloc( HEADER_SIZE );
await fd.read( headerChunk, 0, HEADER_SIZE, null );
sejas marked this conversation as resolved.
Show resolved Hide resolved

if ( Buffer.compare( headerChunk, HEADER_CHUNK_EOF ) === 0 ) {
return null;
}

const name = readFromBuffer( headerChunk, 0, 255 );
const size = parseInt( readFromBuffer( headerChunk, 255, 269 ), 10 );
const mTime = readFromBuffer( headerChunk, 269, 281 );
const prefix = readFromBuffer( headerChunk, 281, HEADER_SIZE );

return {
name,
size,
mTime,
prefix,
};
}

/**
* Reads a block of data from a .wpress file and writes it to a file.
*
* @param {fs.promises.FileHandle} fd - The file handle to read from.
* @param {Header} header - The header of the file to read.
* @param {string} outputPath - The path to write the file to.
*/
async function readBlockToFile( fd: fs.promises.FileHandle, header: Header, outputPath: string ) {
const outputFilePath = path.join( outputPath, header.prefix, header.name );
fse.ensureDirSync( path.dirname( outputFilePath ) );
sejas marked this conversation as resolved.
Show resolved Hide resolved
const outputStream = fs.createWriteStream( outputFilePath );

let totalBytesToRead = header.size;
while ( totalBytesToRead > 0 ) {
let bytesToRead = 512;
sejas marked this conversation as resolved.
Show resolved Hide resolved
if ( bytesToRead > totalBytesToRead ) {
bytesToRead = totalBytesToRead;
}

if ( bytesToRead === 0 ) {
break;
}

const buffer = Buffer.alloc( bytesToRead );
const data = await fd.read( buffer, 0, bytesToRead, null );
sejas marked this conversation as resolved.
Show resolved Hide resolved
outputStream.write( buffer );

totalBytesToRead -= data.bytesRead;
}

outputStream.close();
}

export class BackupHandlerWpress extends EventEmitter implements BackupHandler {
private bytesRead: number;
private eof: Buffer;

constructor() {
super();
this.bytesRead = 0;
this.eof = Buffer.alloc( HEADER_SIZE, '\0' );
}

/**
* Lists all files in a .wpress backup file by reading the headers sequentially.
*
* It opens the .wpress file, reads each header to get the file names, and stores them in an array.
* The function continues reading headers until it reaches the end of the file.
*
* @param {BackupArchiveInfo} file - The backup archive information, including the file path.
* @returns {Promise<string[]>} - A promise that resolves to an array of file names.
*/
async listFiles( file: BackupArchiveInfo ): Promise< string[] > {
const fileNames: string[] = [];

if ( ! fs.existsSync( file.path ) ) {
sejas marked this conversation as resolved.
Show resolved Hide resolved
throw new Error( `Input file at location "${ file.path }" could not be found.` );
}

const inputFile = await fs.promises.open( file.path, 'r' );

// Read all of the headers and file data into memory.
try {
let header;
do {
header = await readHeader( inputFile );
if ( header ) {
fileNames.push( path.join( header.prefix, header.name ) );
await inputFile.read( Buffer.alloc( header.size ), 0, header.size, null );
}
} while ( header );
} finally {
await inputFile.close();
}

return fileNames;
}

/**
* Extracts files from a .wpress backup file into a specified extraction directory.
*
* @param {BackupArchiveInfo} file - The backup archive information, including the file path.
* @param {string} extractionDirectory - The directory where the files will be extracted.
* @returns {Promise<void>} - A promise that resolves when the extraction is complete.
*/
async extractFiles( file: BackupArchiveInfo, extractionDirectory: string ): Promise< void > {
return new Promise( ( resolve, reject ) => {
( async () => {
try {
if ( ! fs.existsSync( file.path ) ) {
sejas marked this conversation as resolved.
Show resolved Hide resolved
throw new Error( `Input file at location "${ file.path }" could not be found.` );
}

fse.emptyDirSync( extractionDirectory );
sejas marked this conversation as resolved.
Show resolved Hide resolved

const inputFile = await fs.promises.open( file.path, 'r' );

let offset = 0;

let header;
while ( ( header = await readHeader( inputFile ) ) !== null ) {
if ( ! header ) {
break;
}

await readBlockToFile( inputFile, header, extractionDirectory );
offset = offset + HEADER_SIZE + header.size;
sejas marked this conversation as resolved.
Show resolved Hide resolved
}

await inputFile.close();
resolve();
} catch ( err ) {
reject( err );
}
} )();
} );
}

/**
* Checks if the provided file is a valid backup file.
*
* A valid backup file should have a header size of 4377 bytes and the last 4377 bytes should be 0.
*
* @param {BackupArchiveInfo} file - The backup archive information, including the file path.
* @returns {boolean} - True if the file is valid, otherwise false.
*/
isValid( file: BackupArchiveInfo ): boolean {
const fd = fs.openSync( file.path, 'r' );
const fileSize = fs.fstatSync( fd ).size;
sejas marked this conversation as resolved.
Show resolved Hide resolved

if ( fs.readSync( fd, this.eof, 0, HEADER_SIZE, fileSize - HEADER_SIZE ) !== HEADER_SIZE ) {
sejas marked this conversation as resolved.
Show resolved Hide resolved
fs.closeSync( fd );
return false;
}

if ( this.eof.toString() !== Buffer.alloc( HEADER_SIZE, '\0' ).toString() ) {
sejas marked this conversation as resolved.
Show resolved Hide resolved
fs.closeSync( fd );
sejas marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

fs.closeSync( fd );
sejas marked this conversation as resolved.
Show resolved Hide resolved
return true;
}
}
10 changes: 9 additions & 1 deletion src/lib/import-export/import/import-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ import {
LocalImporter,
PlaygroundImporter,
SQLImporter,
WpressImporter,
} from './importers/importer';
import { BackupArchiveInfo, NewImporter } from './types';
import { JetpackValidator, SqlValidator, LocalValidator, PlaygroundValidator } from './validators';
import {
JetpackValidator,
SqlValidator,
LocalValidator,
PlaygroundValidator,
WpressValidator,
} from './validators';
import { Validator } from './validators/validator';

export interface ImporterOption {
Expand Down Expand Up @@ -75,4 +82,5 @@ export const defaultImporterOptions: ImporterOption[] = [
{ validator: new LocalValidator(), importer: LocalImporter },
{ validator: new SqlValidator(), importer: SQLImporter },
{ validator: new PlaygroundValidator(), importer: PlaygroundImporter },
{ validator: new WpressValidator(), importer: WpressImporter },
];
41 changes: 40 additions & 1 deletion src/lib/import-export/import/importers/importer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { shell } from 'electron';
import { EventEmitter } from 'events';
import fs from 'fs';
import fs, { createReadStream, createWriteStream } from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { createInterface } from 'readline';
import { lstat, move } from 'fs-extra';
import semver from 'semver';
import { DEFAULT_PHP_VERSION } from '../../../../../vendor/wp-now/src/constants';
Expand Down Expand Up @@ -49,6 +50,7 @@ abstract class BaseImporter extends EventEmitter implements Importer {

try {
await move( sqlFile, tmpPath );
await this.prepareSqlFile( tmpPath );
const { stderr, exitCode } = await server.executeWpCliCommand(
`sqlite import ${ sqlTempFile } --require=/tmp/sqlite-command/command.php`
);
Expand All @@ -69,6 +71,10 @@ abstract class BaseImporter extends EventEmitter implements Importer {
this.emit( ImportEvents.IMPORT_DATABASE_COMPLETE );
}

protected async prepareSqlFile( _tmpPath: string ): Promise< void > {
// This method can be overridden by subclasses to prepare the SQL file before import.
}

protected async replaceSiteUrl( siteId: string ) {
const server = SiteServer.get( siteId );
if ( ! server ) {
Expand Down Expand Up @@ -308,3 +314,36 @@ export class SQLImporter extends BaseImporter {
}
}
}

export class WpressImporter extends BaseBackupImporter {
protected async parseMetaFile(): Promise< MetaFileData | undefined > {
return undefined;
}

protected async prepareSqlFile( tmpPath: string ): Promise< void > {
const tempOutputPath = `${ tmpPath }.tmp`;
const readStream = createReadStream( tmpPath, 'utf8' );
const writeStream = createWriteStream( tempOutputPath, 'utf8' );

const rl = createInterface( {
input: readStream,
crlfDelay: Infinity,
} );

rl.on( 'line', ( line: string ) => {
writeStream.write( line.replace( /SERVMASK_PREFIX/g, 'wp' ) + '\n' );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could expand on why wpress format needs to modify the SQL code before importing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you refer that we should add a comment explaining why why we replace this fake prefix?
It seems that wpress replaces the original prefix with SERVMASK_PREFIX, probably to avoid prefix collisions when importing to a shared database.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you refer that we should add a comment explaining why why we replace this fake prefix? It seems that wpress replaces the original prefix with SERVMASK_PREFIX, probably to avoid prefix collisions when importing to a shared database.

Yep, I was curious why we need this. Thanks @sejas for explaining it 🙇 !

} );

await new Promise( ( resolve, reject ) => {
rl.on( 'close', resolve );
rl.on( 'error', reject );
} );

await new Promise( ( resolve, reject ) => {
writeStream.end( resolve );
writeStream.on( 'error', reject );
} );

await fsPromises.rename( tempOutputPath, tmpPath );
}
}
1 change: 1 addition & 0 deletions src/lib/import-export/import/validators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './sql-validator';
export * from './jetpack-validator';
export * from './local-validator';
export * from './playground-validator';
export * from './wpress-validator';
Loading
Loading