Add Private Media feature: attachments private by default#458
Open
mikelittle wants to merge 31 commits intomasterfrom
Open
Add Private Media feature: attachments private by default#458mikelittle wants to merge 31 commits intomasterfrom
mikelittle wants to merge 31 commits intomasterfrom
Conversation
Implement the Private Media feature which makes uploaded media attachments private by default. Attachments only become publicly accessible when used in published content, marked as a site icon, flagged as legacy, or manually overridden via the UI/CLI. Key components: - Visibility logic with priority-based public/private determination - Post lifecycle hooks to track publish/unpublish transitions - Content parser to extract attachment references from block content - AWS signing parameter sanitisation on save - Signed URL support for draft/preview contexts - Query compatibility layer (always active) for private post_status - map_meta_cap filter so authors/editors can access private attachments - Media library UI: row actions, bulk actions, modal visibility dropdown - WP-CLI commands: migrate, set-visibility, fix-attachments - 68 integration tests with S3 ACL mocking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a "Visibility" column to the media library list table showing Private/Public status with forced override indicators. Add acceptance tests for the media library UI: upload defaults to private, Make Public and Make Private row actions, and Remove Override action. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add docs/private-media.md explaining the feature from a user perspective: how uploads are private by default, how they become public when content is published, how to manage visibility via quick actions, bulk actions and the media editor sidebar, and configuration options for developers. Includes screenshots of the media library visibility column and row actions, with placeholders for additional screenshots to be added manually (bulk confirmation, modal sidebar, post actions, success notice). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5 tasks
1. Add per-request static cache for attachment privacy checks to avoid repeated DB lookups when S3 Uploads calls the filter for every URL of every image size (~200 calls per media library page load). 2. Route signed image URLs through tachyon_url() in REST content.raw so X-Amz-* params get bundled into a presign query parameter. Without this, the browser hits CloudFront directly with S3 signing params which it cannot validate (host mismatch), resulting in 404. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Normalize HTML-encoded ampersands (&) before parsing query strings - Restore original separator style after filtering AWS parameters - Add tests for HTML-encoded ampersands in URLs with single and multiple non-AWS params
`set_attachment_visibility()` now uses a direct `$wpdb->update()` + `clean_post_cache()` instead of `wp_update_post()`. This avoids triggering nested hook cascades (image srcset generation, etc.) that cause the OOM error when called from within the parent post's transition_post_status handler. The S3 ACL update and CDN cache purge still run normally via their own calls.
Two root causes prevented attachments from transitioning to public on publish and AWS params from being stripped from stored content: 1. wp_insert_post_data receives slashed content (\" instead of "), so the sanitisation regex never matched src attributes. Fixed by wrapping with wp_unslash()/wp_slash(). 2. HM\Media\Cropper's filter_attachment_meta_data uses a static cache on wp_get_attachment_metadata that doesn't invalidate when we update metadata. After add_post_reference() saved the used_in_published_post key, the subsequent check_attachment_is_public() read returned stale cached data without our key. Fixed by passing $unfiltered=true to all wp_get_attachment_metadata() calls in our visibility functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On the Altis platform, Tachyon URLs omit the uploads/ prefix (e.g. /tachyon/2026/03/img.jpg instead of /tachyon/uploads/2026/03/img.jpg). The regex only matched the uploads/ variant, so clean_url returned the Tachyon URL unchanged. This caused replace_private_urls() to fail to sign URLs for previews and REST responses after sanitisation stripped the original presign params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
replace_private_urls() was passing the content-parsed URL (Tachyon or canonical WordPress path) to add_s3_signed_params_to_attachment_url(), but S3 Uploads' get_s3_location_for_url() can only resolve S3 bucket URLs or wp_upload_dir() base URLs. The content-parsed URL didn't match either, so signing silently failed and previews showed broken images. Now uses wp_get_attachment_url() (which returns the S3 URL) with query params stripped, so the S3 location can be resolved and signing works. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Logs attachment discovery, signing resolution, and str_replace results to trace why preview signing isn't working on the deployed server. To be removed after debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tachyon_url() on an already-signed URL produced a malformed URL with two '?' characters (e.g. ?presign=...?resize=1920,1285). Now we call tachyon_url() on the unsigned base URL first (to get proper sizing params), then manually append the S3 signing params as a presign query parameter with correct & separator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tachyon's filter_the_content runs at priority 999999 on the_content, rewriting image URLs and adding resize/fit params. Our preview signing was at priority 999 (before Tachyon), so Tachyon stripped the presign params we added. Now runs at priority 1000000 (after Tachyon). When the content URL is already a Tachyon URL (with resize params from Tachyon), we use it as the base and append presign via add_query_arg, preserving both the resize params and the S3 signing params. In REST API context (where Tachyon hasn't processed the content), we build the Tachyon URL first via tachyon_url() then append presign. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sign_rest_content() was modifying content.raw, which the block editor parses to reconstruct blocks and saves back to the database. This broke image display in the editor (block parser couldn't match the mangled URL to the attachment) and risked persisting expiring AWS credentials to the database on save. Now signs content.rendered instead, leaving raw content untouched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…default priority Three changes to align with a proven working implementation: 1. Pass signed S3 URL directly to tachyon_url() so Tachyon receives AWS params as top-level query params, instead of wrapping them in a presign parameter that Tachyon may not support. 2. Revert REST signing to content.raw (was content.rendered). The block editor needs signed URLs in raw content to display private images. The sanitisation module strips AWS params before save, preventing credential persistence. 3. Use default the_content filter priority instead of 1000000. Running before Tachyon lets Tachyon process the signed URLs directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The AWS SDK signs against the canonical S3 host (bucket.s3.region.amazonaws.com) but the URL may use a CDN or custom hostname. Add a filter on s3_uploads_presigned_url that dynamically reads the bucket and region from S3_Uploads\Plugin to rebuild the correct canonical URL, ensuring the signature matches the host. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Images are served through Tachyon which handles S3 auth itself, so only rewrite presigned URLs for non-image media (PDFs, videos, etc.). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs prevented file/PDF attachments from being treated as private in draft post editor and preview contexts: 1. Content parser pattern only matched "src" in Gutenberg block attributes, but file blocks use "href". Changed to match both. This affected URL signing and publish/unpublish lifecycle transitions. 2. replace_private_urls() stripped query params from wp_get_attachment_url() (already signed) and re-signed, but for non-images the rewrite_presigned_url_to_canonical_s3 filter had rewritten the host to bucket.s3.region.amazonaws.com which get_s3_location_for_url() cannot resolve. Now uses the already-signed URL directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On the Altis platform, S3_UPLOADS_BUCKET includes a path prefix after the bucket name (e.g. "hmn-uploads-eu/platform-test"). The AWS SDK signs against the full S3 key including this prefix, but rewrite_presigned_url_to_canonical_s3 was only using get_s3_bucket() which strips the prefix. This caused a signature mismatch for non-image private attachments on deployed environments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audio blocks were not detected by the content parser because:
- The block comment only has {"id":N} (no "src" in JSON attributes)
- The rendered HTML had no wp-audio-{id} class for identification
This meant audio attachments in draft posts got no presigned URLs in
preview, and publish/unpublish transitions didn't track them.
Fixes:
- Extend pattern 5 to match <!-- wp:audio --> in addition to wp:video
- Add render_block_core/audio filter to inject wp-audio-{id} class
- Add pattern 7 to match wp-audio-{id} class in rendered content
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover the non-image block types that were previously untested:
- Content parser: audio block comment, wp-audio-{id} class, file block
href attribute, file block full markup
- Post lifecycle: publish/unpublish transitions for file, audio and
video attachments, plus mixed media post with all types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add audio to the list of detected media types - Fix post_attachment_ids filter example to use correct 3-arg signature - Update hooks table description for post_attachment_ids filter - Add missing private_media/do_purge_cdn_cache action to hooks reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Expose privateMediaOverride and privateMediaIsPublic to the JS attachment model via wp_prepare_attachment_for_js, then patch Attachment.Library's render to overlay icon badges: - Lock icon (dark) for private attachments - Globe icon (blue) for forced-public attachments - No badge for naturally public attachments (used in published content) Also switches asset versioning to filemtime() for automatic cache busting during development, and documents the grid view badges. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…video posters
1. Treat post_status='inherit' attachments as public — these predate the
private media feature and should remain accessible without requiring
the migration CLI command.
2. Skip canonical S3 rewrite for image-extension URLs (PDF preview
thumbnails) so they route through Tachyon instead of breaking the
presigned URL signature.
3. Replace JSON-escaped URLs in block comments alongside HTML URLs when
signing draft content, so the block editor reads signed URLs from
block attributes and file blocks render correctly on reload.
4. Add video poster image support:
- Track poster attachment IDs via private_media/post_attachment_ids
filter so they follow publish/unpublish lifecycle transitions
- Sign private poster URLs in preview and REST contexts
- Strip AWS params from poster attributes on save (both HTML and JSON)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jerico
reviewed
Apr 3, 2026
The Gutenberg editor / preview / REST signing fixes (commit d61cca6) addressed PDF preview thumbnails and video posters in `the_content` and REST contexts, but the admin media library (grid view, list view, and the wp.media modal) goes through `wp_prepare_attachment_for_js` and `wp_get_attachment_image_src`, which were not covered. Root cause: 1. `wp_get_attachment_url($pdf_id)` returns a presigned URL on the regional S3 host (e.g. `bucket.s3.eu-west-1.amazonaws.com`). 2. WP core's `image_downsize` derives sub-size URLs by taking dirname() of that URL and swapping in the cover JPEG filename, dropping the query string in the process — so the cover URL is unsigned. 3. S3 Uploads' `wp_get_attachment_image_src` filter tries to sign it via `add_s3_signed_params_to_attachment_url`, which calls `get_s3_location_for_url`. That helper only matches the legacy `bucket.s3.amazonaws.com/` form (no region) or `wp_upload_dir()['baseurl']`, so the regional S3 URL is unresolvable and the URL passes through unsigned. 4. The browser hits 403 on the unsigned cover and the grid tile collapses to ~16px wide. Same root cause for video posters. Fix: - New `sign_non_image_subsize_url()` filter on `wp_get_attachment_image_src` at priority 11 (after S3 Uploads' priority 10). For private non-image attachments, it normalises the cover URL to the upload baseurl form, then re-runs S3 Uploads' signing — which now resolves and produces a presigned URL bound to the cover JPEG's actual S3 key. - `add_visibility_to_js()` re-signs URLs in `$response['sizes']` and `$response['image']['src']` for the same case, since `wp_prepare_attachment_for_js` builds those URLs through paths that don't all flow through `wp_get_attachment_image_src`. - New `sign_cover_url_for_attachment()` helper performs the host normalisation and delegation, and bails cleanly on already-signed URLs, missing S3 Uploads, missing upload dir, or paths without `/uploads/`. Also updates the Private Media documentation: - Reorders "What You See in the Media Library" so grid view is the primary section (matching the Altis default), with list view documented as the alternative. - Adds five missing screenshots (grid, notice, bulk-confirm, modal-sidebar, post-actions) and refreshes the existing two. - Removes the four TODO screenshot placeholders. - Fixes the WP-CLI command names: `set-visibility` → `set_visibility` and `fix-attachments` → `fix_attachments` (the actual subcommands use underscores; the hyphenated form errors out). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The migrate loop always re-queried `paged => 1` after processing each
batch. In a real run this happened to terminate by accident: each
attachment's status was flipped to `publish`, removing it from the
`['inherit','private']` filter, so page 1 shrank with each iteration
until empty.
In `--dry-run` mode no rows are modified, so the same page 1 returned
the same N rows forever and the command hung.
Fix:
- Count the total once up front via a separate count query so the
progress bar is sized correctly without depending on the working
query's `found_posts`.
- Walk forward with a `do { ... } while ( $processed < $total )` loop.
- In dry-run mode, increment `$page` between iterations so we paginate.
- In a real run, stay on page 1 (processed rows fall out of the result
set, so page 1 always returns the next batch).
- Switch the working query to `no_found_rows => true` since we no
longer rely on its `found_posts`.
- Move the `wp_get_attachment_metadata` call inside the `! $dry_run`
branch — there's no reason to read metadata in dry-run mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add a "Which command should I use?" decision table at the top of the WP-CLI section so readers can pick the right tool quickly. - Expand `migrate` to explain what "legacy" means and that it should be run once on first enable. - Expand `set_visibility` to clarify that overrides take precedence over the automatic lifecycle, and note that "Remove Override" is UI-only. - Rename "Fix Inconsistencies" to "Repair Attachment References" and expand it substantially: explain what it actually reconciles, the concrete situations where it's needed, that the date range is on post date (not modified date), and how to override the default 30-day window. Add three worked examples. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New screenshots without broken pdf image. Reduced file sizes
Five `error_log` calls left over from the preview/signing debugging sessions, all explicitly tagged `DEBUG: temporary logging`. Removed along with the now-unused `$did_replace` and `$replaced` locals that only existed to feed those log lines. No functional change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Contributor
Author
It really is ready for a full review now. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the Private Media feature: uploaded media is private by default and only becomes publicly accessible when it's used in published content (or manually overridden, or marked as a site icon, or grandfathered in via the legacy migration).
The goal is to stop authors accidentally leaking unpublished assets — drafts, embargoed PR images, in-progress edits — by URL-guessing or scraping. Once a post is published, all media it references is flipped to public automatically. Once it's unpublished, anything that's no longer referenced anywhere flips back to private.
Enabled by default on every site except the Global Media Library site. Disabled via:
{ "extra": { "altis": { "modules": { "media": { "private-media": false } } } } }How it works
The feature lives in
Altis\Media\Private_Media, bootstrapped frominc/private_media/namespace.php. Bootstrap is deferred untilmuplugins_loadedbecause some checks (get_site_meta,is_global_site) aren't available earlier.Visibility resolution
visibility.phpdefines a single canonical function —check_attachment_is_public()— that resolves whether an attachment should be public. The decision is priority-based, evaluated in this order:legacy_attachmentflag in metadata, set by the migration command) → publicOverrides are stored in attachment metadata under
altis_override_visibility('public','private', or absent for automatic). This decision is the source of truth for both the UI badges and the actual S3 ACL.The function is wrapped in a per-request static cache (
is_attachment_private_cached) because S3 Uploads's3_uploads_is_attachment_privatefilter calls it ~200x per page on a media-heavy view, which used to cause 30-second media library timeouts.Post lifecycle
post_lifecycle.phphookstransition_post_statusandsave_postto track which attachments are referenced by which published posts:Content_Parser, plus the featured image, plus anything added by theprivate_media/post_attachment_idsfilter. For each referenced attachment, callsadd_post_reference()and re-evaluates its visibility. If it should now be public, the attachment'spost_statusflips topublishand its S3 ACL flips topublic-read. The full ID list is stashed on the post inaltis_private_media_postmeta so unpublish can compare against it.private.The status flip uses
$wpdb->update()+clean_post_cache()directly rather thanwp_update_post()becausewp_update_post()re-firestransition_post_statuson the attachment, which used to OOM on bulk publishes.Content parser
content_parser.phpextracts attachment IDs and URLs from post content. It handles:wp:image {"id":N}and<img class="wp-image-N">wp:gallery {"ids":[...]}wp:cover {"id":N}plus the background image URLwp:media-text {"mediaId":N}wp:video {"id":N}and the<video>posterattributewp:audio {"id":N}wp:file {"id":N}(for PDFs and downloadable files)attachment_url_to_postid()extract_attachments_from_content()returns a deduplicated list of[attachment_id, modified_url]tuples. Theprivate_media/post_attachment_idsfilter lets sites add custom sources (gallery custom fields, ACF image lists, etc.).Sanitisation
sanitisation.phphookswp_insert_post_datato strip AWS signing query params from attachment URLs before content is saved to the database. This is essential: when previewing a draft we sign every private image URL, but those signatures expire and shouldn't be persisted. The sanitiser handles:wp_unslash→ process →wp_slashround-trip —wp_insert_post_datareceives slashed input).&between query params (normalises before splitting).\/) forms in block comment attributes.Signed URLs (preview / draft / file blocks)
signed_urls.phpmakes private images visible in contexts where they aren't yet "public" via the lifecycle:the_content(only whenis_preview()). Walks attachments viaContent_Parser, callswp_get_attachment_url()(already returns a presigned URL for private attachments via S3 Uploads' filter), then for images routes throughtachyon_url()so Tachyon receives the AWS params as top-level query params and replays the signed S3 fetch server-side.rest_prepare_{post_type}and signs URLs incontent.raw(the block editor reads fromraw, notrendered, and the sanitiser will strip the params again on save).replace_private_poster_urls()does the same for<video poster="...">attributes, looked up viaattachment_url_to_postid().Two important guards:
disable_srcset_in_previewreturns an emptywp_calculate_image_srcsetin preview / REST contexts, because every srcset variant would need its own signature and responsive image switching can't pick the right one anyway.rewrite_presigned_url_to_canonical_s3(filter ons3_uploads_presigned_url, priority 999) rewrites the URL host to the canonical regional S3 endpoint (bucket.s3.region.amazonaws.com) so the Host-bound signature matches the request — UNLESS the path is an image extension (.jpg,.png,.gif,.webp), in which case we leave it alone so it can route through Tachyon. Images go through Tachyon for both auth and resizing; non-images (PDF, MP4, etc.) get the canonical-S3 form so the browser hits S3 directly with a valid signature.Admin media library UI
ui.phpadds the visible bits:manage_media_columns/manage_media_custom_column. Renders one of four states:Private,Public,Public (forced),Private (forced).wp_prepare_attachment_for_jsaddsprivateMediaOverrideandprivateMediaIsPublicto the JS attachment model;assets/private-media.cssrenders an absolute-positioned badge in each tile (lock icon for private, globe for forced public, no badge for naturally public).upload.php?action=private_media_set_*handled byhandle_row_actions().Set Visibilityredirects toadmin.php?action=private_media_bulk_visibilitywhich renders a confirmation form (target visibility + selected attachments) before applying.attachment_fields_to_editadds anAttachment Statusdisplay, aVisibility Overridedropdown, aUsed Inlist of post links, and aLegacyflag if applicable. Edits save viaattachment_fields_to_saveand via an AJAX handler for in-modal changes.Publish image(s)andUnpublish image(s)on the Posts/Pages list, which callhandle_publish/handle_unpublishdirectly for that one post (useful for repairing a single post without runningfix_attachments).PDF cover and video poster sub-size signing
wp_get_attachment_url($pdf_id)returns a presigned URL on the regional S3 host. WP core'simage_downsizederives sub-size URLs by takingdirname()of that URL and substituting the cover JPEG filename, dropping the query string in the process — so the cover URL is unsigned. S3 Uploads'wp_get_attachment_image_srcfilter then tries to sign it viaadd_s3_signed_params_to_attachment_url, which callsget_s3_location_for_url. That helper only matches the legacybucket.s3.amazonaws.com/form (no region) and the upload baseurl — the regional S3 URL matches neither, so the URL passes through unsigned and the browser hits 403, collapsing the grid tile to ~16px wide.To work around this without modifying S3 Uploads:
sign_non_image_subsize_url()is hooked onwp_get_attachment_image_srcat priority 11 (after S3 Uploads at 10). For private non-image attachments, it normalises the URL host to the upload baseurl form, then re-runs S3 Uploads' signing — which now resolves and produces a presigned URL bound to the cover JPEG's actual S3 key.add_visibility_to_js()re-signs URLs in$response['sizes']and$response['image']['src']for the same reason, sincewp_prepare_attachment_for_jsbuilds those URLs through paths that don't all flow throughwp_get_attachment_image_src.The same code path handles video poster images. There's a related upstream bug filed against
humanmade/s3-uploadsrequesting thatget_s3_location_for_urlrecognise regional S3 URLs natively, after which this workaround can be removed.Query compatibility
query_compat.phpaddspublishandprivateto the defaultpost_statuslist for attachment queries viapre_get_posts. This is always active, even when the feature is disabled, so that any private attachments left over from a previous-enabled state still appear in media queries and don't disappear from the library.Capability handling
map_meta_capfilter grantsread_postfor private attachments to any user withupload_files(authors+). Without this, editors couldn't see private attachments in the media library because WP's default permissions block reads onprivatepost statuses you don't own.Site icon
site_icon.phpautomatically marks the configured site icon as forced-public (it needs to be reachable on every page). An explicit force-private override on the site icon takes precedence — that's a deliberate footgun if you want it.WP-CLI
class-cli-command.phpexposes three commands. All support--dry-run:wp private-media migrate [--dry-run]— one-time, run when first enabling on a site with existing content. Marks every existing attachment aslegacy_attachmentand flips its status topublishso it stays accessible. Iterates with proper forward pagination in dry-run mode (the bug where dry-run hung was fixed inbbde9b6).wp private-media set_visibility <public|private> <id|filename> [--dry-run]— sets a manual override on a single attachment, equivalent to the row action. Resolves attachments by numeric ID or by filename via_wp_attached_filepostmeta.wp private-media fix_attachments [--start-date=<date>] [--end-date=<date>] [--dry-run] [--verbose]— repair tool. Walks every published post in a date range (default last 30 days, post date not modified date), re-scans content for attachments, re-records references, and re-evaluates visibility. Use after content imports, SQL edits, or filter changes. Defaults to allowed post types (anything with editor support), falling back topost/page.Configuration filters
private_media/allowed_post_typesprivate_media/post_meta_attachment_keysprivate_media/post_attachment_idsprivate_media/update_s3_aclprivate_media/purge_cdn_cacheprivate_media/do_purge_cdn_cacheFiles
inc/private_media/— 11 PHP source files (visibility, post_lifecycle, content_parser, sanitisation, signed_urls, query_compat, site_icon, ui, class-cli-command, cli, namespace)assets/private-media.{js,css}— grid badges and modal sidebar wiringinc/namespace.phpandload.php— bootstrap integrationcomposer.json— feature flag defaultdocs/private-media.md+docs/assets/*.png— full user documentation including 7 screenshots, with grid view as the primary mode (matching the Altis default)Automated tests
Integration: 79 tests across 8 files in
tests/integration/PrivateMedia/:VisibilityTestContentParserTestPostLifecycleTestSanitisationTest&normalisation, JSON-escaped URLs in block commentsOverrideTestQueryCompatTestSiteIconTestSignedUrlsTestRun with:
composer dev-tools codecept run integration -p vendor/altis/media/tests/Each test that touches S3 uses
S3MockTraitto short-circuitprivate_media/update_s3_aclso the suite runs without an S3 client.Acceptance:
PrivateMediaCest(4 cases) intests/acceptance/. These exercise the admin UI end-to-end via WPWebDriver. They cannot be run from CI in the same job as integration tests because they require a Chrome container and a TTY, so they're run manually:Recommended manual tests
The local stack covers the lifecycle and UI thoroughly via the automated suite. The areas worth eyeballing manually before merge are the ones that depend on real S3 and real Tachyon/CDN behaviour, which the local setup mocks or stubs.
On a local stack (
composer server start)Public (forced)in list view.Private (forced)in list view.Set Visibility→Force Public. Confirm the confirmation screen lists the right files and applies cleanly.wp private-media migrate --dry-runthenwp private-media migrate. Confirm dry-run completes (the previously-broken hang).wp private-media fix_attachments --dry-run --verbose. Confirm the per-post output looks sane.On a real AWS environment
These cover paths that depend on actual S3, CloudFront, and Tachyon — they cannot be exercised locally.
curla presigned URL on the deployed domain. Should return 200. (Previously failing onplatform-test.aws.hmn.mddue to a CloudFront Host-header forwarding gap — verify the platform team's fix is in place by comparing against PlayStation production, where it works.)content.rawin the REST response).curlthe direct S3 URL. Should return 200 public. Repeat for unpublish.wp_update_post→ direct$wpdb->updatefix).$unfiltered=truefix).8c7dbcfcover-sub-size signing fix.)wp private-media migrateon a site with pre-existing uploads — run dry-run first, then for real. Confirm legacy uploads stay accessible after migration.wp private-media fix_attachments— run against a date range that includes recently-imported posts. Confirm the reference list and visibility are reconciled correctly.wp private-media set_visibility public <id>— set against a real S3 attachment and confirm the ACL flips.