--/ * * manifest.json complete sets only (manifest.partial.json while in progress) * db/tables.json schema per table * db/schema.sql CREATE statements (readable, runnable by hand) * db/NNNN-.MMMMM.sql.gz * objects/ab/[.gz] each file's bytes, by checksum; scripts gzip-compressed (Set_Files) * index/files.jsonl {r, p|p64, t, s, m, h, gz?}: every file's real path, size and time * (format 1 sets, 1.0.2 and earlier: files//, a mirrored tree, and "lp" in rows) * index/chunks.jsonl {table, file, rows, sql_bytes, gz_bytes, sha256} * * Complete sets are never modified, so unchanged files' objects can be hardlinked between sets. * * @package AccessNow\Replica */ namespace AccessNow\Replica\Sets; use AccessNow\Replica\Logger; use AccessNow\Replica\Storage; defined( 'ABSPATH' ) || exit; /** * Set storage. */ final class Set_Store { /** * Creates an empty set folder and returns its ID. * * @param string $source_home Source home URL. */ public static function create( $source_home ) { $host = (string) wp_parse_url( $source_home, PHP_URL_HOST ); $path = trim( (string) wp_parse_url( $source_home, PHP_URL_PATH ), '/' ); $slug = sanitize_title( $host . ( '' !== $path ? '-' . $path : '' ) ); $id = gmdate( 'Ymd-His' ) . '-' . ( '' !== $slug ? substr( $slug, 0, 60 ) : 'site' ) . '-' . strtolower( wp_generate_password( 4, false, false ) ); // Format 2 keeps files in objects/, made as they arrive (Set_Files); nothing makes files/. foreach ( array( 'db', 'index' ) as $sub ) { wp_mkdir_p( self::dir( $id ) . $sub ); } @chmod( self::dir( $id ), 0700 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod return $id; } /** * Absolute set directory with trailing slash. * * @param string $id Set ID. */ public static function dir( $id ) { return Storage::path( 'sets/' . $id . '/' ); } /** * Whether a string is a valid set ID. * * @param string $id ID. */ public static function valid_id( $id ) { return is_string( $id ) && (bool) preg_match( '/^\d{8}-\d{6}-[a-z0-9-]{1,64}$/', $id ); } /** * Manifest (complete or partial) or null. * * @param string $id Set ID. * @return array|null */ public static function manifest( $id ) { if ( ! self::valid_id( $id ) ) { return null; } $m = Storage::read_json( self::dir( $id ) . 'manifest.json' ); if ( is_array( $m ) ) { return $m; } $m = Storage::read_json( self::dir( $id ) . 'manifest.partial.json' ); return is_array( $m ) ? $m : null; } /** * All sets, newest first. * * @return array[] Manifests with 'set_id'. */ public static function all() { if ( ! Storage::exists() ) { return array(); // No backup yet, and listing must not create the folder. } $out = array(); foreach ( (array) @scandir( Storage::path( 'sets' ) ) as $name ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( self::valid_id( (string) $name ) ) { $m = self::manifest( $name ); if ( $m ) { $m['set_id'] = $name; $out[] = $m; } } } usort( $out, static function ( $a, $b ) { return strcmp( $b['set_id'], $a['set_id'] ); } ); return $out; } /** * Newest complete set of a source, for incremental pulls. * * @param string $source_home Source home URL. * @param string $exclude Set ID to skip. * @return string|null */ public static function latest_complete( $source_home, $exclude = '' ) { foreach ( self::all() as $m ) { if ( $m['set_id'] !== $exclude && 'complete' === ( $m['status'] ?? '' ) && untrailingslashit( (string) ( $m['source']['home'] ?? '' ) ) === untrailingslashit( $source_home ) ) { return $m['set_id']; } } return null; } /** * Deletes a set. * * @param string $id Set ID. */ public static function delete( $id ) { if ( self::valid_id( $id ) && is_dir( self::dir( $id ) ) ) { Storage::rrmdir( untrailingslashit( self::dir( $id ) ) ); Logger::activity( 'backup', 'Backup set deleted: ' . $id ); } } /** * Keeps the newest $keep complete sets of a source; never deletes the last complete one. * * @param string $source_home Source home. * @param int $keep Sets to keep (0 = all). */ public static function apply_retention( $source_home, $keep ) { if ( $keep <= 0 ) { return; } $complete = array(); foreach ( self::all() as $m ) { if ( 'complete' === ( $m['status'] ?? '' ) && untrailingslashit( (string) ( $m['source']['home'] ?? '' ) ) === untrailingslashit( $source_home ) ) { $complete[] = $m['set_id']; } } foreach ( array_slice( $complete, max( 1, $keep ) ) as $id ) { self::delete( $id ); } } }