*/ public static function list_recovery() { $rec = self::recovery_dir(); if ( is_wp_error( $rec ) ) { return array(); } $out = array(); foreach ( (array) glob( trailingslashit( $rec ) . 'aisv-recovery-*.aisv' ) as $path ) { if ( ! is_file( $path ) ) { continue; } $out[] = array( 'file' => basename( $path ), 'path' => $path, 'size' => (int) filesize( $path ), 'mtime' => (int) filemtime( $path ), ); } usort( $out, static function ( $a, $b ) { return $b['mtime'] <=> $a['mtime']; } ); return $out; } /** * Resolve a recovery point by basename (traversal-safe). * * @param string $file Basename. * @return string|false */ public static function recovery_path_for( $file ) { $file = basename( (string) $file ); if ( '' === $file || ! preg_match( '/^aisv-recovery-[0-9A-Za-z\-]+\.aisv$/', $file ) ) { return false; } $rec = self::recovery_dir(); if ( is_wp_error( $rec ) ) { return false; } $path = trailingslashit( $rec ) . $file; return is_file( $path ) ? $path : false; } /** * Keep only the newest $keep recovery points; delete the rest (with sidecars). * * @param int $keep Keep newest N (min 1). * @return int Number removed. */ public static function prune_recovery( $keep ) { $keep = max( 1, (int) $keep ); $removed = 0; foreach ( array_slice( self::list_recovery(), $keep ) as $rp ) { if ( self::delete_all_volumes( $rp['path'] ) ) { ++$removed; } } return $removed; } /** * A scratch working directory for a running job (holds the file list, staging). * * @param int $job_id Job id. * @return string|\WP_Error */ public static function work_dir( $job_id ) { $dir = self::dir(); if ( is_wp_error( $dir ) ) { return $dir; } $work = trailingslashit( $dir ) . 'work-' . (int) $job_id; if ( ! is_dir( $work ) ) { wp_mkdir_p( $work ); } return $work; } /** * Apply retention: keep the newest $keep backups and delete any older than * $days days. Also removes the matching history rows. Returns count deleted. * * @param int $keep Keep newest N (min 1). * @param int $days Delete older than N days (0 = disabled). * @return int */ public static function prune( $keep, $days = 0 ) { $keep = max( 1, (int) $keep ); $deleted = array(); $files = self::list_files(); foreach ( array_slice( $files, $keep ) as $f ) { if ( self::delete_all_volumes( $f['path'] ) ) { $deleted[] = $f['file']; } } if ( $days > 0 ) { $cutoff = time() - ( (int) $days * DAY_IN_SECONDS ); foreach ( self::list_files() as $f ) { if ( $f['mtime'] < $cutoff ) { if ( self::delete_all_volumes( $f['path'] ) ) { $deleted[] = $f['file']; } } } } $deleted = array_unique( $deleted ); if ( $deleted ) { global $wpdb; foreach ( $deleted as $fn ) { $wpdb->delete( $wpdb->prefix . 'aisiteark_backups', array( 'filename' => $fn ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } return count( $deleted ); } /** * Recursively remove a job's working directory. * * @param int $job_id Job id. * @return void */ public static function clean_work_dir( $job_id ) { $dir = self::dir(); if ( is_wp_error( $dir ) ) { return; } self::rrmdir( trailingslashit( $dir ) . 'work-' . (int) $job_id ); } /** * Sweep abandoned temp files: stale per-job work dirs (crashed backups) and * leftover chunked-upload / URL-fetch parts. Age-gated so an in-flight job's * files are never removed. Safe to run on a schedule and after each backup. * * @param int $max_age_hours Remove temp items older than this many hours. * @return int Number of items (dirs + files) removed. */ public static function sweep_temp( $max_age_hours = 12 ) { $dir = self::dir(); if ( is_wp_error( $dir ) ) { return 0; } $cutoff = time() - ( max( 1, (int) $max_age_hours ) * HOUR_IN_SECONDS ); $removed = 0; // Orphaned per-job working directories (work-). foreach ( (array) glob( trailingslashit( $dir ) . 'work-*', GLOB_ONLYDIR ) as $work ) { if ( (int) filemtime( $work ) < $cutoff ) { self::rrmdir( $work ); ++$removed; } } // Abandoned chunked-upload / URL-fetch parts (upload-*.part, urlfetch-*.part). foreach ( (array) glob( trailingslashit( $dir ) . '*.aisv.part' ) as $part ) { if ( is_file( $part ) && (int) filemtime( $part ) < $cutoff ) { wp_delete_file( $part ); if ( ! is_file( $part ) ) { ++$removed; } } } // Orphaned integrity sidecars whose archive is gone. foreach ( (array) glob( trailingslashit( $dir ) . '*.aisv.sha256' ) as $sc ) { if ( ! is_file( substr( $sc, 0, -7 ) ) ) { // strip ".sha256". wp_delete_file( $sc ); if ( ! is_file( $sc ) ) { ++$removed; } } } // Orphaned Encrypt-then-MAC sidecars whose archive is gone. foreach ( (array) glob( trailingslashit( $dir ) . '*.aisv.mac' ) as $sc ) { if ( ! is_file( substr( $sc, 0, -4 ) ) ) { // strip ".mac". wp_delete_file( $sc ); if ( ! is_file( $sc ) ) { ++$removed; } } } return $removed; } /** * Suggested archive filename — includes the site host so a backup is identifiable at a * glance (e.g. aisv-backup-example-com-20260811-120000-Ab12.aisv). * * @return string */ public static function filename() { $site = self::site_slug(); return 'aisv-backup-' . ( '' !== $site ? $site . '-' : '' ) . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 4, false ) . '.aisv'; } /** * Sub-folder that holds the actual backup archives, kept tidy and separate from recovery * points and work files. Lives under the (web-blocked) store dir, so it inherits the same * .htaccess protection. New backups are written here; archives created before this existed * remain in the store root and are still found (see list_files() / path_for()). * * @return string|\WP_Error */ public static function backup_dir() { $dir = self::dir(); if ( is_wp_error( $dir ) ) { return $dir; } $bdir = trailingslashit( $dir ) . 'backup'; if ( ! is_dir( $bdir ) ) { wp_mkdir_p( $bdir ); } return $bdir; } /** * The site's host as a filename/folder-safe slug (lowercase, no "www.", only a-z0-9-), * e.g. "example.com" -> "example-com". Empty string if it can't be determined. * * @return string */ public static function site_slug() { $host = wp_parse_url( home_url(), PHP_URL_HOST ); $host = is_string( $host ) ? strtolower( $host ) : ''; $host = (string) preg_replace( '/^www\./', '', $host ); $host = (string) preg_replace( '/[^a-z0-9]+/', '-', $host ); return trim( $host, '-' ); } /** * Every volume file for a base archive path (base + base.2 + base.3 …), in order. * A plain single-volume archive returns just itself. * * @param string $base Base archive path (volume 1). * @return string[] */ public static function volumes_for( $base ) { $out = array(); $index = 1; while ( true ) { $vp = ( 1 === $index ) ? $base : $base . '.' . $index; if ( ! is_file( $vp ) ) { break; } $out[] = $vp; ++$index; } return $out; } /** * Stored archives, newest first. `size` sums all volumes; `volumes` is the count. * * @return array */ public static function list_files() { $dir = self::dir(); if ( is_wp_error( $dir ) ) { return array(); } $out = array(); $seen = array(); // New backups live in the backup/ sub-folder; archives from before it existed are in the // store root. Scan both (backup/ first so it wins on a duplicate name). Only base volumes // end in ".aisv"; continuation parts (".aisv.2") don't match. $globs = array(); $bdir = self::backup_dir(); if ( ! is_wp_error( $bdir ) ) { $globs[] = trailingslashit( $bdir ) . '*.aisv'; } $globs[] = trailingslashit( $dir ) . '*.aisv'; foreach ( $globs as $glob ) { foreach ( (array) glob( $glob ) as $path ) { if ( ! is_file( $path ) ) { continue; } $base = basename( $path ); if ( isset( $seen[ $base ] ) ) { continue; } $seen[ $base ] = true; $vols = self::volumes_for( $path ); $size = 0; foreach ( $vols as $v ) { $size += (int) filesize( $v ); } $out[] = array( 'file' => $base, 'path' => $path, 'size' => $size, 'mtime' => (int) filemtime( $path ), 'volumes' => count( $vols ), ); } } usort( $out, static function ( $a, $b ) { return $b['mtime'] <=> $a['mtime']; } ); return $out; } /** * Resolve a stored archive by basename (traversal-safe). * * @param string $file Basename. * @return string|false */ public static function path_for( $file ) { $file = basename( (string) $file ); if ( '' === $file || ! preg_match( '/^(aisv-backup|uploaded|cloud)-[0-9A-Za-z\-]+\.aisv$/', $file ) ) { return false; } $dir = self::dir(); if ( is_wp_error( $dir ) ) { return false; } // Prefer the backup/ sub-folder (new location); fall back to the store root (archives // created before backup/ existed, and imported uploaded-/cloud- archives). $bdir = self::backup_dir(); if ( ! is_wp_error( $bdir ) ) { $bpath = trailingslashit( $bdir ) . $file; if ( is_file( $bpath ) ) { return $bpath; } } $path = trailingslashit( $dir ) . $file; return is_file( $path ) ? $path : false; } /** * Delete a stored archive by basename. * * @param string $file Basename. * @return bool */ public static function delete_file( $file ) { $path = self::path_for( $file ); if ( ! $path ) { return false; } return self::delete_all_volumes( $path ); } /** * Delete every volume of an archive plus each volume's checksum sidecar. * * @param string $base Base archive path. * @return bool True if the base volume was removed. */ public static function delete_all_volumes( $base ) { $ok = false; foreach ( self::volumes_for( $base ) as $v ) { // G4: also remove the integrity checksum sidecar, if any. if ( is_file( $v . '.sha256' ) ) { wp_delete_file( $v . '.sha256' ); } // Encrypt-then-MAC authenticity sidecar, if any. if ( is_file( $v . '.mac' ) ) { wp_delete_file( $v . '.mac' ); } wp_delete_file( $v ); $removed = ! is_file( $v ); if ( $v === $base ) { $ok = (bool) $removed; } } return $ok; } /** * Recursively delete a directory. * * @param string $dir Directory. * @return void */ private static function rrmdir( $dir ) { if ( ! is_dir( $dir ) ) { return; } $items = scandir( $dir ); foreach ( (array) $items as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = $dir . '/' . $item; if ( is_dir( $path ) ) { self::rrmdir( $path ); } else { wp_delete_file( $path ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir @rmdir( $dir ); } }