diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f0b3b0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+tests/_output/
diff --git a/assets/private-media.css b/assets/private-media.css
new file mode 100644
index 0000000..edd8364
--- /dev/null
+++ b/assets/private-media.css
@@ -0,0 +1,59 @@
+/**
+ * Private Media — Styles.
+ *
+ * Lock icon overlay and status label styles for the media library.
+ */
+
+/* Lock icon overlay on private attachments in grid view */
+.private-media-lock {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: rgba( 0, 0, 0, 0.7 );
+ color: #fff;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ line-height: 28px;
+ text-align: center;
+ font-size: 16px;
+ z-index: 10;
+ pointer-events: none;
+}
+
+/* Ensure attachment preview is positioned for overlay */
+.attachment-preview {
+ position: relative;
+}
+
+/* Status label in media details */
+.compat-field-private_media_status .field strong {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 12px;
+}
+
+/* Visibility override dropdown */
+.private-media-override {
+ width: 100%;
+ max-width: 250px;
+}
+
+/* List table row action links */
+.row-actions .private_media_public a,
+.row-actions .private_media_private a,
+.row-actions .private_media_auto a {
+ cursor: pointer;
+}
+
+/* Bulk action confirmation page */
+.private-media-bulk-confirm .attachment-list {
+ margin: 10px 0;
+ padding: 0;
+ list-style: disc inside;
+}
+
+.private-media-bulk-confirm .attachment-list li {
+ padding: 3px 0;
+}
diff --git a/assets/private-media.js b/assets/private-media.js
new file mode 100644
index 0000000..a290fb7
--- /dev/null
+++ b/assets/private-media.js
@@ -0,0 +1,82 @@
+/**
+ * Private Media — Media Library UI.
+ *
+ * Extends the WordPress media modal to add visibility controls
+ * and lock icon overlays for private attachments.
+ */
+( function( $, wp ) {
+ 'use strict';
+
+ if ( ! wp || ! wp.media ) {
+ return;
+ }
+
+ /**
+ * Handle visibility dropdown changes via AJAX.
+ */
+ $( document ).on( 'change', '.private-media-override', function() {
+ var $select = $( this );
+ var attachmentId = $select.data( 'attachment-id' );
+ var override = $select.val();
+
+ $.ajax( {
+ url: privateMedia.ajaxUrl,
+ type: 'POST',
+ data: {
+ action: 'private_media_set_visibility',
+ nonce: privateMedia.nonce,
+ attachment_id: attachmentId,
+ override: override
+ },
+ success: function( response ) {
+ if ( response.success ) {
+ // Update the status display.
+ var $status = $select.closest( '.attachment-details, .compat-field-private_media_override' )
+ .siblings( '.compat-field-private_media_status' )
+ .find( 'strong' );
+
+ if ( response.data.override === 'public' ) {
+ $status.text( 'Forced Public' );
+ } else if ( response.data.override === 'private' ) {
+ $status.text( 'Forced Private' );
+ } else {
+ $status.text( response.data.status === 'public' ? 'Public' : 'Private' );
+ }
+
+ // Refresh the attachment in the library if possible.
+ if ( wp.media.frame && wp.media.frame.library ) {
+ var attachment = wp.media.frame.library.get( attachmentId );
+ if ( attachment ) {
+ attachment.fetch();
+ }
+ }
+ }
+ }
+ } );
+ } );
+
+ /**
+ * Add lock icon overlay to private attachments in grid view.
+ */
+ if ( wp.media.view && wp.media.view.Attachment ) {
+ var OriginalAttachment = wp.media.view.Attachment;
+
+ wp.media.view.Attachment = OriginalAttachment.extend( {
+ render: function() {
+ OriginalAttachment.prototype.render.apply( this, arguments );
+
+ var status = this.model.get( 'status' );
+ this.$el.find( '.private-media-lock' ).remove();
+
+ if ( status === 'private' ) {
+ this.$el.find( '.attachment-preview' ).append(
+ ''
+ );
+ }
+
+ return this;
+ }
+ } );
+ }
+
+} )( jQuery, window.wp );
diff --git a/composer.json b/composer.json
index 3eb8f49..c81faa5 100644
--- a/composer.json
+++ b/composer.json
@@ -25,7 +25,17 @@
],
"files": [
"inc/namespace.php",
- "inc/global_assets/namespace.php"
+ "inc/global_assets/namespace.php",
+ "inc/private_media/namespace.php",
+ "inc/private_media/visibility.php",
+ "inc/private_media/content_parser.php",
+ "inc/private_media/post_lifecycle.php",
+ "inc/private_media/sanitisation.php",
+ "inc/private_media/signed_urls.php",
+ "inc/private_media/ui.php",
+ "inc/private_media/site_icon.php",
+ "inc/private_media/query_compat.php",
+ "inc/private_media/cli.php"
]
},
"extra": {
diff --git a/docs/assets/private-media-bulk-actions.png b/docs/assets/private-media-bulk-actions.png
new file mode 100644
index 0000000..c68a4f7
Binary files /dev/null and b/docs/assets/private-media-bulk-actions.png differ
diff --git a/docs/assets/private-media-list.png b/docs/assets/private-media-list.png
new file mode 100644
index 0000000..aa14a89
Binary files /dev/null and b/docs/assets/private-media-list.png differ
diff --git a/docs/assets/private-media-row-actions.png b/docs/assets/private-media-row-actions.png
new file mode 100644
index 0000000..9b111fb
Binary files /dev/null and b/docs/assets/private-media-row-actions.png differ
diff --git a/docs/private-media.md b/docs/private-media.md
new file mode 100644
index 0000000..d3c8fe6
--- /dev/null
+++ b/docs/private-media.md
@@ -0,0 +1,228 @@
+# Private Media
+
+The Private Media feature makes uploaded media **private by default**. When you upload an image, video, PDF or any other file to
+the media library, it cannot be accessed by visitors via its direct URL. The file only becomes publicly accessible when it is used
+in published content, or when you choose to make it public manually.
+
+This prevents uploaded media from being discoverable or shareable before the content it belongs to has been published.
+
+Private Media is enabled by default for all sites except the Global Media Library site. It can be disabled via configuration:
+
+```json
+{
+ "extra": {
+ "altis": {
+ "modules": {
+ "media": {
+ "private-media": false
+ }
+ }
+ }
+ }
+}
+```
+
+## How It Works
+
+### Uploads Are Private by Default
+
+When you upload a file to the media library it starts as private. Anyone trying to access the file via its direct URL will receive
+an access denied error. Under the hood, this is implemented by setting the file's S3 storage permissions to private.
+
+Private files are still fully available to logged-in users who can upload media (authors, editors and administrators). You can
+browse them in the media library, insert them into posts, and use them as featured images as normal.
+
+### Files Become Public When Content Is Published
+
+When you publish a post or page, all the media used in it automatically becomes publicly accessible:
+
+- Images, videos and files embedded in the content are detected.
+- The featured image (post thumbnail) is included.
+- The files' storage permissions are updated so they can be accessed by visitors.
+
+If the same file is used in more than one published post, it stays public until all of those posts are unpublished.
+
+### Files Return to Private When Content Is Unpublished
+
+When you move a published post back to draft, trash it, or otherwise unpublish it:
+
+- All files that were referenced by that post are re-evaluated.
+- If a file is no longer used by any published post (and you haven't manually set it to public), it returns to private.
+
+The same re-evaluation happens when you edit a published post and remove an image from its content.
+
+## What You See in the Media Library
+
+### The Visibility Column
+
+The media library list view includes a **Visibility** column that shows the current access status of each file:
+
+
+
+The possible statuses are:
+
+- **Private** — the default. The file cannot be accessed via its direct URL.
+- **Public** — the file is used in published content and can be accessed by visitors.
+- **Public (forced)** — you have manually set this file to always be public, regardless of whether it is used in published content.
+- **Private (forced)** — you have manually set this file to always be private, even if it is used in published content.
+
+### Changing Visibility with Quick Actions
+
+Hover over any file in the media library list to see the available quick actions:
+
+
+
+- **Make Public** — makes the file publicly accessible, even if it is not used in any published content.
+- **Make Private** — makes the file private, even if it is currently used in published content.
+- **Remove Override** — removes your manual setting and returns the file to automatic management (appears after you have used Make
+ Public or Make Private).
+
+
+
+### Changing Visibility for Multiple Files
+
+To change the visibility of several files at once:
+
+1. Select the files using the checkboxes in the media library list.
+2. Choose **Set Visibility** from the **Bulk actions** dropdown.
+3. Click **Apply**.
+4. On the confirmation screen, choose the target visibility and click **Apply**.
+
+
+
+### Changing Visibility in the Media Editor
+
+When editing a post, click on a file in the media browser to see its details in the sidebar. The **Visibility Override** dropdown
+lets you change the visibility setting directly without leaving the editor.
+
+
+
+The sidebar also shows:
+
+- The current access status of the file.
+- Which published posts are using the file (if any).
+- Whether the file is a legacy (pre-migration) upload.
+
+## Managing Post Attachments
+
+Posts and pages in the admin list include two additional quick actions for working with their media:
+
+- **Publish image(s)** — scans the post content and ensures all files used in it are publicly accessible. Useful if images appear
+ broken after a migration or configuration change.
+- **Unpublish image(s)** — removes the post's association with its files, which may cause them to become private if no other
+ published posts use them.
+
+
+
+## Previewing Draft Content
+
+When you preview a draft post, private images in the content are displayed using temporary signed URLs that expire after a short
+period. This means you can see exactly how the post will look without needing to make the images public first.
+
+## Existing Uploads and Migration
+
+When Private Media is first enabled on a site that already has uploaded files, all existing files remain publicly accessible. They
+are marked as "legacy" uploads so they continue to work without disruption.
+
+To apply this marking, run the migration command after enabling the feature:
+
+```
+wp private-media migrate
+```
+
+Use `--dry-run` to preview what would change without making any modifications.
+
+## Site Icon
+
+The site icon (favicon) is always treated as public, since it needs to be accessible on every page. This applies automatically
+when you set a site icon in **Settings > General**. If you specifically force the site icon to private, that override takes
+precedence.
+
+## Configuration
+
+### Disabling the Feature
+
+Set `private-media` to `false` in your Altis configuration to disable the feature entirely. When disabled, a compatibility layer
+remains active to ensure any files that were previously set to private status are still included in media queries, preventing
+them from disappearing.
+
+### Adding Custom Post Types
+
+By default, all post types that support the content editor are tracked for media references. If you have a custom post type that
+uses media but does not register editor support, you can include it:
+
+```php
+add_filter( 'private_media/allowed_post_types', function ( array $types ) : array {
+ $types[] = 'my_custom_type';
+ return $types;
+} );
+```
+
+### Registering Custom Image Fields
+
+If your theme or plugin stores file IDs in custom fields (similar to how WordPress stores the featured image), you can register
+those field names so they are included when scanning a post for media references:
+
+```php
+add_filter( 'private_media/post_meta_attachment_keys', function ( array $keys ) : array {
+ $keys[] = '_custom_header_image_id';
+ $keys[] = '_secondary_image_id';
+ return $keys;
+} );
+```
+
+### Adding Custom Media Sources
+
+For more advanced cases where files are associated with posts through non-standard means, you can add additional file IDs to the
+scan results:
+
+```php
+add_filter( 'private_media/post_attachment_ids', function ( array $ids, WP_Post $post ) : array {
+ // Include files from a custom gallery field.
+ $gallery_ids = get_post_meta( $post->ID, '_gallery_images', true );
+ if ( is_array( $gallery_ids ) ) {
+ $ids = array_merge( $ids, $gallery_ids );
+ }
+ return $ids;
+}, 10, 2 );
+```
+
+## WP-CLI Commands
+
+The following commands are available for managing private media from the command line. All commands support `--dry-run` to preview
+changes without applying them.
+
+### Migrate Existing Uploads
+
+```
+wp private-media migrate [--dry-run]
+```
+
+Marks all existing uploads as legacy (public) and ensures their status is correct. Run this when first enabling Private Media on a
+site with existing content.
+
+### Set Visibility for a Specific File
+
+```
+wp private-media set-visibility [--dry-run]
+```
+
+Manually set the visibility for a specific file by its ID or filename.
+
+### Fix Inconsistencies
+
+```
+wp private-media fix-attachments [--start-date=] [--end-date=] [--dry-run] [--verbose]
+```
+
+Re-evaluates the visibility of all files, correcting any inconsistencies. Supports date range filtering for targeted fixes.
+
+## Hooks and Filters Reference
+
+| Filter | Description |
+|-------------------------------------------|---------------------------------------------------------------------------|
+| `private_media/allowed_post_types` | Array of post types to track for media references. |
+| `private_media/post_meta_attachment_keys` | Array of field names that store file IDs (like the featured image field). |
+| `private_media/post_attachment_ids` | Array of file IDs found in a post. Receives `$ids` and `$post`. |
+| `private_media/update_s3_acl` | Intercept storage permission updates. Return non-null to short-circuit. |
+| `private_media/purge_cdn_cache` | Intercept CDN cache clearing. Return non-null to short-circuit. |
diff --git a/docs/readme.md b/docs/readme.md
index 448c8e2..522a7d7 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -6,4 +6,7 @@ The Media module includes features and enhancements related to uploaded media. T
includes [lazy loading](./lazy-loading.md), [AI powered image classification](./image-recognition.md)
plus [dynamic image manipulation](./dynamic-images.md) and cropping tools.
+The module also provides [private media](./private-media.md) support, making uploaded attachments private by default and only
+publicly accessible when used in published content.
+
In addition to the above features there are low-level enhancements including SVG sanitization to mitigate XSS attacks.
diff --git a/inc/namespace.php b/inc/namespace.php
index 39a834b..2fae03c 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -28,6 +28,9 @@ function bootstrap() {
// Set up global asset management.
Global_Assets\bootstrap();
+
+ // Set up private media feature.
+ Private_Media\bootstrap();
}
/**
diff --git a/inc/private_media/class-cli-command.php b/inc/private_media/class-cli-command.php
new file mode 100644
index 0000000..59fbb54
--- /dev/null
+++ b/inc/private_media/class-cli-command.php
@@ -0,0 +1,293 @@
+ 'attachment',
+ 'post_status' => [ 'inherit', 'private' ],
+ 'posts_per_page' => 100,
+ 'paged' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => false,
+ ] );
+
+ $total = $query->found_posts;
+ $processed = 0;
+
+ WP_CLI::log( sprintf( 'Found %d attachments to migrate.', $total ) );
+
+ $progress = Utils\make_progress_bar( 'Migrating attachments', $total );
+
+ while ( $query->have_posts() ) {
+ foreach ( $query->posts as $attachment_id ) {
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+ if ( ! is_array( $metadata ) ) {
+ $metadata = [];
+ }
+
+ if ( ! $dry_run ) {
+ $metadata['legacy_attachment'] = true;
+ wp_update_attachment_metadata( $attachment_id, $metadata );
+
+ wp_update_post( [
+ 'ID' => $attachment_id,
+ 'post_status' => 'publish',
+ ] );
+
+ Visibility\update_s3_acl( $attachment_id, 'public-read' );
+ }
+
+ $processed++;
+ $progress->tick();
+ }
+
+ // Next page.
+ $query = new WP_Query( [
+ 'post_type' => 'attachment',
+ 'post_status' => [ 'inherit', 'private' ],
+ 'posts_per_page' => 100,
+ 'paged' => 1,
+ 'fields' => 'ids',
+ 'no_found_rows' => false,
+ ] );
+
+ if ( empty( $query->posts ) ) {
+ break;
+ }
+ }
+
+ $progress->finish();
+ WP_CLI::success( sprintf( '%d attachments %s.', $processed, $dry_run ? 'would be migrated' : 'migrated' ) );
+ }
+
+ /**
+ * Set the visibility of a specific attachment.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : The visibility to set. 'public' or 'private'.
+ *
+ *
+ * : The attachment ID or filename.
+ *
+ * [--dry-run]
+ * : Preview changes without applying them.
+ *
+ * ## EXAMPLES
+ *
+ * wp private-media set-visibility public 123
+ * wp private-media set-visibility private my-image.jpg --dry-run
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ */
+ public function set_visibility( array $args, array $assoc_args ) : void { // phpcs:ignore HM.Functions.NamespacedFunctions
+ $visibility = $args[0];
+ $identifier = $args[1];
+ $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false );
+
+ if ( ! in_array( $visibility, [ 'public', 'private' ], true ) ) {
+ WP_CLI::error( 'Visibility must be "public" or "private".' );
+ }
+
+ // Resolve attachment ID from filename or ID.
+ $attachment_id = $this->resolve_attachment_id( $identifier );
+
+ if ( ! $attachment_id ) {
+ WP_CLI::error( sprintf( 'Attachment not found: %s', $identifier ) );
+ }
+
+ $post = get_post( $attachment_id );
+ if ( ! $post || $post->post_type !== 'attachment' ) {
+ WP_CLI::error( sprintf( 'ID %d is not an attachment.', $attachment_id ) );
+ }
+
+ WP_CLI::log( sprintf(
+ 'Setting attachment %d (%s) to %s.',
+ $attachment_id,
+ $post->post_title,
+ $visibility
+ ) );
+
+ if ( ! $dry_run ) {
+ Visibility\set_override( $attachment_id, $visibility );
+ }
+
+ WP_CLI::success( $dry_run ? 'Would update attachment.' : 'Attachment updated.' );
+ }
+
+ /**
+ * Fix attachment visibility for published posts in a date range.
+ *
+ * Scans published posts and re-evaluates all their attachments.
+ *
+ * ## OPTIONS
+ *
+ * [--start-date=]
+ * : Start date (Y-m-d). Defaults to 30 days ago.
+ *
+ * [--end-date=]
+ * : End date (Y-m-d). Defaults to today.
+ *
+ * [--dry-run]
+ * : Preview changes without applying them.
+ *
+ * [--verbose]
+ * : Show detailed output for each post.
+ *
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
+ * @return void
+ */
+ public function fix_attachments( array $args, array $assoc_args ) : void { // phpcs:ignore HM.Functions.NamespacedFunctions
+ $start_date = Utils\get_flag_value( $assoc_args, 'start-date', gmdate( 'Y-m-d', strtotime( '-30 days' ) ) );
+ $end_date = Utils\get_flag_value( $assoc_args, 'end-date', gmdate( 'Y-m-d' ) );
+ $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false );
+ $verbose = Utils\get_flag_value( $assoc_args, 'verbose', false );
+
+ if ( $dry_run ) {
+ WP_CLI::log( 'Dry run mode — no changes will be made.' );
+ }
+
+ $post_types = Post_Lifecycle\is_allowed_post_type( 'post' ) ? get_post_types_by_support( 'editor' ) : [];
+ if ( empty( $post_types ) ) {
+ $post_types = [ 'post', 'page' ];
+ }
+
+ $query = new WP_Query( [
+ 'post_type' => $post_types,
+ 'post_status' => 'publish',
+ 'date_query' => [
+ [
+ 'after' => $start_date,
+ 'before' => $end_date,
+ 'inclusive' => true,
+ ],
+ ],
+ 'posts_per_page' => 50,
+ 'paged' => 1,
+ 'no_found_rows' => false,
+ ] );
+
+ $total = $query->found_posts;
+ WP_CLI::log( sprintf( 'Found %d published posts in date range %s to %s.', $total, $start_date, $end_date ) );
+
+ $progress = Utils\make_progress_bar( 'Fixing attachments', $total );
+ $fixed = 0;
+ $page = 1;
+
+ while ( $query->have_posts() ) {
+ foreach ( $query->posts as $post ) {
+ $attachment_ids = Post_Lifecycle\get_post_attachment_ids( $post );
+
+ if ( $verbose ) {
+ WP_CLI::log( sprintf(
+ 'Post %d (%s): %d attachments found.',
+ $post->ID,
+ $post->post_title,
+ count( $attachment_ids )
+ ) );
+ }
+
+ foreach ( $attachment_ids as $attachment_id ) {
+ if ( ! $dry_run ) {
+ Visibility\add_post_reference( $attachment_id, $post->ID );
+ Visibility\set_attachment_visibility( $attachment_id );
+ }
+ $fixed++;
+ }
+
+ if ( ! $dry_run ) {
+ update_post_meta( $post->ID, 'altis_private_media_post', $attachment_ids );
+ }
+
+ $progress->tick();
+ }
+
+ $page++;
+ $query = new WP_Query( [
+ 'post_type' => $post_types,
+ 'post_status' => 'publish',
+ 'date_query' => [
+ [
+ 'after' => $start_date,
+ 'before' => $end_date,
+ 'inclusive' => true,
+ ],
+ ],
+ 'posts_per_page' => 50,
+ 'paged' => $page,
+ 'no_found_rows' => false,
+ ] );
+ }
+
+ $progress->finish();
+ WP_CLI::success( sprintf(
+ '%d attachment references %s across %d posts.',
+ $fixed,
+ $dry_run ? 'would be fixed' : 'fixed',
+ $total
+ ) );
+ }
+
+ /**
+ * Resolve an attachment ID from an ID number or filename.
+ *
+ * @param string $identifier The attachment ID or filename.
+ * @return int|null The attachment ID, or null if not found.
+ */
+ private function resolve_attachment_id( string $identifier ) : ?int {
+ if ( is_numeric( $identifier ) ) {
+ return (int) $identifier;
+ }
+
+ // Search by filename.
+ global $wpdb;
+ $attachment_id = $wpdb->get_var( $wpdb->prepare(
+ "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s LIMIT 1",
+ '%' . $wpdb->esc_like( $identifier )
+ ) );
+
+ return $attachment_id ? (int) $attachment_id : null;
+ }
+}
diff --git a/inc/private_media/cli.php b/inc/private_media/cli.php
new file mode 100644
index 0000000..90710fa
--- /dev/null
+++ b/inc/private_media/cli.php
@@ -0,0 +1,17 @@
+ tags with wp-image-{id} class.
+ // Matches class="...wp-image-123..." and extracts the src attribute.
+ if ( preg_match_all( '/class="[^"]*wp-image-(\d+)[^"]*"[^>]*src="([^"]+)"/s', $content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $results[] = [
+ 'attachment_id' => (int) $match[1],
+ 'attachment_url' => clean_url( $match[2] ),
+ 'modified_url' => $match[2],
+ ];
+ }
+ }
+
+ // Also match src before class.
+ if ( preg_match_all( '/src="([^"]+)"[^>]*class="[^"]*wp-image-(\d+)[^"]*"/s', $content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $results[] = [
+ 'attachment_id' => (int) $match[2],
+ 'attachment_url' => clean_url( $match[1] ),
+ 'modified_url' => $match[1],
+ ];
+ }
+ }
+
+ // 2. tags with data-full-url attribute (and wp-image class).
+ if ( preg_match_all( '/class="[^"]*wp-image-(\d+)[^"]*"[^>]*data-full-url="([^"]+)"/s', $content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $results[] = [
+ 'attachment_id' => (int) $match[1],
+ 'attachment_url' => clean_url( $match[2] ),
+ 'modified_url' => $match[2],
+ ];
+ }
+ }
+
+ // 3. Gutenberg block attributes: "imageUrl":"...","imageId":N
+ if ( preg_match_all( '/"imageUrl"\s*:\s*"([^"]+)"\s*,\s*"imageId"\s*:\s*(\d+)/', $content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $url = wp_unslash( $match[1] );
+ $results[] = [
+ 'attachment_id' => (int) $match[2],
+ 'attachment_url' => clean_url( $url ),
+ 'modified_url' => $url,
+ ];
+ }
+ }
+
+ // 4. Gutenberg block attributes: "id":N,"src":"..." (e.g. video/file blocks).
+ if ( preg_match_all( '/"id"\s*:\s*(\d+)\s*,\s*"src"\s*:\s*"([^"]+)"/', $content, $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $match ) {
+ $url = wp_unslash( $match[2] );
+ $results[] = [
+ 'attachment_id' => (int) $match[1],
+ 'attachment_url' => clean_url( $url ),
+ 'modified_url' => $url,
+ ];
+ }
+ }
+
+ // 5. Video blocks: with