target URL so content and meta can be rewritten. * * Uploads are tagged with _mspc_source_key ("{blog_id}:{attachment_id}") so * repeat copies from the same source always resolve to the same target file. * * @package MultiSitePostCloner * @license GPL-2.0-or-later */ namespace MSPC; if ( ! defined( 'ABSPATH' ) ) { exit; } class Attachment_Sideloader { const SOURCE_KEY_META = '_mspc_source_key'; /** @var array> source_id => captured metadata */ private $source = array(); /** @var array source_id => new target attachment ID (0 = failed) */ private $id_map = array(); /** @var array source URL => target URL */ private $url_map = array(); /** @var int Target post new uploads are attached to. */ private $parent_id = 0; /** @var int */ private $successes = 0; /** @var int */ private $failures = 0; /** @var int Same source reused twice within one copy. */ private $reused = 0; /** @var int Found already in the target media library. */ private $existing = 0; /** @var bool */ private $deps_loaded = false; /** * Capture source-side metadata. MUST be called BEFORE switch_to_blog(). * * @param int $source_attachment_id */ public function capture_source( $source_attachment_id ) { $source_attachment_id = (int) $source_attachment_id; if ( ! $source_attachment_id || isset( $this->source[ $source_attachment_id ] ) ) { return; } $post = get_post( $source_attachment_id ); if ( ! $post || 'attachment' !== $post->post_type ) { return; } $path = (string) get_attached_file( $source_attachment_id ); $original = function_exists( 'wp_get_original_image_path' ) ? (string) wp_get_original_image_path( $source_attachment_id ) : ''; if ( '' === $original || ! file_exists( $original ) ) { $original = $path; } $this->source[ $source_attachment_id ] = array( 'id' => $source_attachment_id, 'blog_id' => (int) get_current_blog_id(), 'url' => (string) wp_get_attachment_url( $source_attachment_id ), 'path' => $path, 'copy_path' => $original, 'filesize' => ( $original && file_exists( $original ) ) ? (int) filesize( $original ) : 0, 'urls' => self::collect_urls( $source_attachment_id ), 'attach_meta' => wp_get_attachment_metadata( $source_attachment_id ), 'title' => (string) $post->post_title, 'caption' => (string) $post->post_excerpt, 'description' => (string) $post->post_content, 'alt' => (string) get_post_meta( $source_attachment_id, '_wp_attachment_image_alt', true ), 'mime_type' => (string) $post->post_mime_type, ); } /** * Target post that newly uploaded media should be attached to. * Call AFTER the target post is inserted. * * @param int $post_id */ public function set_parent( $post_id ) { $this->parent_id = (int) $post_id; } /** * Resolve a captured source attachment on the *current* (target) blog: * reuse if it already exists, otherwise upload. MUST run after switch_to_blog. * * @param int $source_attachment_id * @return int Target attachment ID, or 0 on failure. */ public function sideload_to_target( $source_attachment_id ) { $source_attachment_id = (int) $source_attachment_id; if ( ! $source_attachment_id ) { return 0; } if ( array_key_exists( $source_attachment_id, $this->id_map ) ) { if ( $this->id_map[ $source_attachment_id ] > 0 ) { $this->reused++; } return $this->id_map[ $source_attachment_id ]; } if ( ! isset( $this->source[ $source_attachment_id ] ) ) { $this->failures++; $this->id_map[ $source_attachment_id ] = 0; return 0; } $this->load_admin_deps(); $meta = $this->source[ $source_attachment_id ]; // 1. Already in the target media library? $new_id = (bool) apply_filters( 'mspc_reuse_existing_attachments', true, $meta ) ? $this->find_existing_on_target( $meta ) : 0; if ( $new_id ) { $this->existing++; $this->attach_if_unattached( $new_id ); } else { // 2. Upload it. $new_id = $this->copy_from_filesystem( $meta ); if ( ! $new_id ) { $new_id = $this->copy_from_url( $meta ); } if ( $new_id ) { $this->apply_attachment_meta( (int) $new_id, $meta ); update_post_meta( (int) $new_id, self::SOURCE_KEY_META, $meta['blog_id'] . ':' . $meta['id'] ); $this->successes++; } else { $this->failures++; } } $this->id_map[ $source_attachment_id ] = (int) $new_id; if ( $new_id ) { $this->record_urls( $source_attachment_id, (int) $new_id ); } return (int) $new_id; } /** * Was a given source attachment captured pre-switch? * * @param int $source_attachment_id * @return bool */ public function has_capture( $source_attachment_id ) { return isset( $this->source[ (int) $source_attachment_id ] ); } /** * @return array source URL => target URL */ public function get_url_map() { return $this->url_map; } /** * @return array source attachment ID => target ID */ public function get_id_map() { return $this->id_map; } /** * Look for the same file already in the current (target) media library. * * @param array $meta * @return int */ private function find_existing_on_target( array $meta ) { global $wpdb; // a) Previously copied by this plugin from this exact source. $by_key = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'meta_key' => self::SOURCE_KEY_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $meta['blog_id'] . ':' . $meta['id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); if ( $by_key ) { $file = get_attached_file( (int) $by_key[0] ); if ( $file && file_exists( $file ) ) { return (int) $by_key[0]; } } // b) Same file name with identical bytes (e.g. uploaded manually). if ( empty( $meta['path'] ) || empty( $meta['copy_path'] ) || ! file_exists( $meta['copy_path'] ) ) { return 0; } $name = wp_basename( $meta['path'] ); $candidates = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND ( meta_value = %s OR meta_value LIKE %s ) LIMIT 20", $name, '%/' . $wpdb->esc_like( $name ) ) ); $src_md5 = null; foreach ( (array) $candidates as $cid ) { $cid = (int) $cid; if ( 'attachment' !== get_post_type( $cid ) ) { continue; } $cmp = function_exists( 'wp_get_original_image_path' ) ? (string) wp_get_original_image_path( $cid ) : ''; if ( '' === $cmp || ! file_exists( $cmp ) ) { $cmp = (string) get_attached_file( $cid ); } if ( '' === $cmp || ! file_exists( $cmp ) || (int) filesize( $cmp ) !== (int) $meta['filesize'] ) { continue; } if ( null === $src_md5 ) { $src_md5 = md5_file( $meta['copy_path'] ); } if ( $src_md5 && md5_file( $cmp ) === $src_md5 ) { update_post_meta( $cid, self::SOURCE_KEY_META, $meta['blog_id'] . ':' . $meta['id'] ); return $cid; } } return 0; } /** * @param int $attachment_id */ private function attach_if_unattached( $attachment_id ) { if ( ! $this->parent_id ) { return; } $att = get_post( $attachment_id ); if ( $att && 0 === (int) $att->post_parent ) { wp_update_post( array( 'ID' => (int) $attachment_id, 'post_parent' => $this->parent_id, ) ); } } /** * Read a local file (already checked to exist and be readable by the * caller) through WP_Filesystem where it is available. * * @param string $path Absolute path. * @return string|false File contents, or false on failure. */ private function read_local_file( $path ) { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( empty( $wp_filesystem ) ) { WP_Filesystem(); // initialised once per request, not per file } if ( ! empty( $wp_filesystem ) ) { return $wp_filesystem->get_contents( $path ); } // WP_Filesystem needs credentials on some hosts; this is a plain local read. return file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } /** * Copy a local file without overwriting, through WP_Filesystem where it * is available. Returns false on any failure (no error suppression). * * @param string $from Absolute source path. * @param string $to Absolute destination path. * @return bool */ private function copy_local_file( $from, $to ) { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( empty( $wp_filesystem ) ) { WP_Filesystem(); // initialised once per request, not per file } if ( ! empty( $wp_filesystem ) ) { return (bool) $wp_filesystem->copy( $from, $to, false ); } return copy( $from, $to ); // phpcs:ignore WordPress.WP.AlternativeFunctions } private function copy_from_filesystem( array $meta ) { $path = ! empty( $meta['copy_path'] ) ? (string) $meta['copy_path'] : (string) $meta['path']; if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) { return 0; } $filename = wp_basename( $path ); $bits = $this->read_local_file( $path ); if ( false === $bits ) { return 0; } $upload = wp_upload_bits( $filename, null, $bits ); if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) { return 0; } $file_path = $upload['file']; $file_url = $upload['url']; $filetype = wp_check_filetype( $filename, null ); $attachment = array( 'post_mime_type' => $filetype['type'] ? $filetype['type'] : $meta['mime_type'], 'post_title' => '' !== $meta['title'] ? $meta['title'] : preg_replace( '/\.[^.]+$/', '', $filename ), 'post_content' => $meta['description'], 'post_excerpt' => $meta['caption'], 'post_status' => 'inherit', 'guid' => $file_url, ); $new_id = wp_insert_attachment( $attachment, $file_path, $this->parent_id, true ); if ( is_wp_error( $new_id ) || ! $new_id ) { return 0; } // Fast path: copy the size files the source already generated. // Fallback: regenerate -scaled + every registered size on the target. $attach_meta = $this->copy_size_files( $meta, (int) $new_id, $file_path ); if ( false === $attach_meta ) { $attach_meta = wp_generate_attachment_metadata( $new_id, $file_path ); } wp_update_attachment_metadata( $new_id, $attach_meta ); return (int) $new_id; } /** * Copy the source's already-generated files (-scaled, every size, PDF * previews) next to the uploaded original and build matching metadata, * instead of re-running image processing. Regenerating sizes is by far * the slowest part of a copy; this turns it into plain file copies. * * Returns false (caller regenerates) whenever anything doesn't line up: * unexpected file names, missing source files, or name collisions. * * @param array $meta Captured source data. * @param int $new_id Target attachment ID. * @param string $target_original Absolute path of the uploaded original. * @return array|false */ private function copy_size_files( array $meta, $new_id, $target_original ) { /** * Copy existing size files instead of regenerating them. * * @param bool $fast * @param array $meta */ if ( ! apply_filters( 'mspc_fast_media_copy', true, $meta ) ) { return false; } $src_meta = isset( $meta['attach_meta'] ) ? $meta['attach_meta'] : null; if ( ! is_array( $src_meta ) || ( empty( $src_meta['sizes'] ) && empty( $src_meta['original_image'] ) ) ) { return false; // Nothing generated worth copying (or not an image/PDF). } if ( empty( $meta['path'] ) || empty( $meta['copy_path'] ) ) { return false; } $src_dir = trailingslashit( dirname( (string) $meta['path'] ) ); $src_stem = pathinfo( (string) $meta['copy_path'], PATHINFO_FILENAME ); $dst_dir = trailingslashit( dirname( $target_original ) ); $dst_stem = pathinfo( $target_original, PATHINFO_FILENAME ); $rename = static function ( $file ) use ( $src_stem, $dst_stem ) { $file = wp_basename( (string) $file ); return ( '' !== $src_stem && 0 === strpos( $file, $src_stem ) ) ? $dst_stem . substr( $file, strlen( $src_stem ) ) : false; }; $copies = array(); // source basename => target basename $new_meta = $src_meta; // Main file (the -scaled copy when the original was large). $main_src = wp_basename( (string) $meta['path'] ); $main_dst = wp_basename( $target_original ); if ( $main_src !== wp_basename( (string) $meta['copy_path'] ) ) { $main_dst = $rename( $main_src ); if ( false === $main_dst ) { return false; } $copies[ $main_src ] = $main_dst; } if ( ! empty( $src_meta['sizes'] ) && is_array( $src_meta['sizes'] ) ) { foreach ( $src_meta['sizes'] as $size => $info ) { if ( empty( $info['file'] ) ) { continue; } $to = $rename( $info['file'] ); if ( false === $to ) { return false; } $copies[ wp_basename( $info['file'] ) ] = $to; $new_meta['sizes'][ $size ]['file'] = $to; } } foreach ( $copies as $from => $to ) { if ( ! is_readable( $src_dir . $from ) || file_exists( $dst_dir . $to ) ) { return false; } } $done = array(); foreach ( $copies as $from => $to ) { if ( ! $this->copy_local_file( $src_dir . $from, $dst_dir . $to ) ) { foreach ( $done as $f ) { wp_delete_file( $f ); } return false; } $done[] = $dst_dir . $to; } if ( ! empty( $src_meta['original_image'] ) ) { $new_meta['original_image'] = wp_basename( $target_original ); } $new_meta['file'] = _wp_relative_upload_path( $dst_dir . $main_dst ); if ( $main_dst !== wp_basename( $target_original ) ) { update_attached_file( $new_id, $dst_dir . $main_dst ); } return $new_meta; } /** * Source attachment IDs captured so far, in a stable order. * * @return int[] */ public function captured_ids() { $ids = array_map( 'intval', array_keys( $this->source ) ); sort( $ids ); return $ids; } private function copy_from_url( array $meta ) { $url = ! empty( $meta['urls']['original'] ) ? (string) $meta['urls']['original'] : (string) $meta['url']; if ( '' === $url ) { return 0; } // SSRF defence: only fetch from hosts that are subsites of this network. if ( ! $this->is_url_in_network( $url ) ) { return 0; } $tmp = download_url( $url ); if ( is_wp_error( $tmp ) ) { return 0; } $file_array = array( 'name' => wp_basename( wp_parse_url( $url, PHP_URL_PATH ) ), 'tmp_name' => $tmp, ); $new_id = media_handle_sideload( $file_array, $this->parent_id, $meta['title'] ); if ( is_wp_error( $new_id ) ) { if ( file_exists( $tmp ) ) { wp_delete_file( $tmp ); } return 0; } return (int) $new_id; } /** * Every public URL of an attachment on the CURRENT blog, keyed so source * and target can be paired: full, size:, original. * * @param int $attachment_id * @return array */ private static function collect_urls( $attachment_id ) { $full = (string) wp_get_attachment_url( $attachment_id ); if ( '' === $full ) { return array(); } $urls = array( 'full' => $full ); $base = trailingslashit( dirname( $full ) ); $meta = wp_get_attachment_metadata( $attachment_id ); if ( is_array( $meta ) ) { if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size => $info ) { if ( ! empty( $info['file'] ) ) { $urls[ 'size:' . $size ] = $base . $info['file']; } } } if ( ! empty( $meta['original_image'] ) ) { $urls['original'] = $base . $meta['original_image']; } } return $urls; } /** * Pair source URLs with the target attachment's URLs by size name. * A size that doesn't exist on the target falls back to the full URL. * * @param int $source_id * @param int $target_id */ private function record_urls( $source_id, $target_id ) { $src = isset( $this->source[ $source_id ]['urls'] ) ? (array) $this->source[ $source_id ]['urls'] : array(); $dst = self::collect_urls( $target_id ); if ( ! $src || empty( $dst['full'] ) ) { return; } foreach ( $src as $key => $from ) { $to = isset( $dst[ $key ] ) ? $dst[ $key ] : $dst['full']; if ( $from && $to && $from !== $to ) { $this->url_map[ $from ] = $to; } } } /** * @param string $url * @return bool */ private function is_url_in_network( $url ) { $parsed = wp_parse_url( $url ); if ( empty( $parsed['host'] ) ) { return false; } $url_host = strtolower( (string) $parsed['host'] ); foreach ( get_sites( array( 'number' => 0 ) ) as $site ) { if ( strtolower( (string) $site->domain ) === $url_host ) { return true; } } return false; } private function apply_attachment_meta( $new_id, array $meta ) { if ( '' !== $meta['alt'] ) { update_post_meta( $new_id, '_wp_attachment_image_alt', $meta['alt'] ); } wp_update_post( array( 'ID' => $new_id, 'post_title' => $meta['title'], 'post_excerpt' => $meta['caption'], 'post_content' => $meta['description'], ) ); } private function load_admin_deps() { if ( $this->deps_loaded ) { return; } if ( ! function_exists( 'media_handle_sideload' ) ) { require_once ABSPATH . 'wp-admin/includes/media.php'; } if ( ! function_exists( 'download_url' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } $this->deps_loaded = true; } /** * @return array{successes:int,failures:int,reused:int,existing:int,total:int,id_map:array} */ public function stats() { return array( 'successes' => $this->successes, 'failures' => $this->failures, 'reused' => $this->reused, 'existing' => $this->existing, 'total' => $this->successes + $this->failures + $this->existing, 'id_map' => $this->id_map, ); } /** * @param int $source_attachment_id * @return array|null */ public function get_capture( $source_attachment_id ) { $source_attachment_id = (int) $source_attachment_id; return isset( $this->source[ $source_attachment_id ] ) ? $this->source[ $source_attachment_id ] : null; } }