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: + +![Media library list view showing the Visibility column](./assets/private-media-list.png) + +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: + +![Media library row actions showing Make Public and Make Private links](./assets/private-media-row-actions.png) + +- **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