Add Private Media feature: attachments private by default#458
Add Private Media feature: attachments private by default#458mikelittle wants to merge 53 commits into
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>
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>
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 integration tests covering all three actions, the unrelated-action passthrough, and the per-attachment edit_post cap check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@wisyhambolu I fixed the bulk action stuff. It was just wrong (Claude problem). Also added some tests to check the functionality too. |
|
We had a customer before that have Tachyon disabled. I'm wondering if we should also consider that, add a check if the Tachyon function exists. |
Reviewer feedback flagged that repurposing attachment `post_status` to reflect S3 ACL state collided with WordPress's content-visibility model and made the feature hard to disable cleanly. Move the state into a dedicated `_altis_media_acl` post meta (values `private` / `public-read`, the literal S3 ACL strings) so attachments stay at WP's default `inherit` status. Consequences: - `grant_private_attachment_read` map_meta_cap workaround removed — inherit attachments don't trigger `read_private_posts` checks, so authors/contributors keep their default read access without help. - `set_attachment_visibility()` no longer needs the `$wpdb->update()` bypass that existed to avoid nested `transition_post_status` cascades (srcset regen OOM) during parent-post saves. `update_post_meta()` doesn't fire that cascade. - `query-compat.php` deleted. It existed to expand `post_status=inherit` queries to also include `publish` and `private` and ran unconditionally to prevent data loss when the feature was disabled. With state in meta the file is unnecessary, and disabling the feature now leaves no runtime footprint. - Priority hierarchy rule 7 changes from "post_status = inherit" to "no `_altis_media_acl` meta" — the meta's presence is the new "touched by the feature" sentinel. - `wp private-media migrate` records `_altis_media_acl = public-read` on each legacy attachment instead of flipping its post_status. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gonna check this next. |
|
Most of it will work without Tachyon. And there is a check, |
The canonical-S3 host rewrite in rewrite_presigned_url_to_canonical_s3() is now gated on function_exists( 'tachyon_url' ). When Tachyon is enabled, images (including PDF preview JPEGs and video poster sub-files of non-image attachments) keep their original host because Tachyon handles S3 auth itself. When Tachyon is absent, the rewrite applies so the AWS signature matches the request the browser will send directly to S3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Updated to handle the case where Tachyon is disabled. |
Flip `private-media` default from true to false. Projects must now
explicitly enable the feature via composer.json:
"extra": { "altis": { "modules": { "media": { "private-media": true } } } }
Refactor is_private_media_active() to accept an optional $module_config
argument for testability, and use empty() so absent/false config both
mean off. bootstrap() now defers to is_private_media_active() for its
early gate. Behaviour is unchanged when the feature is enabled.
Add BootstrapTest covering the four gate-resolution cases. Update the
acceptance Cest doc-comment, the user docs, and the inline code
comments to describe the opt-in semantics. The `wp private-media …`
commands are only registered while the feature is enabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The acceptance tests in tests/acceptance/PrivateMediaCest.php require the private-media feature to be enabled in the test environment — the feature is opt-in and the file's docblock explicitly rejects per-test runtime injection. Pass the new `module-config` input on altis-dev-tools' Module CI workflow so the test root's composer.json has `extra.altis.modules.media.private-media = true`. Also temporarily pins the workflow to the humanmade/altis-dev-tools#1065 branch SHA where that input was added; re-point to @master (or the merge SHA) once #1065 lands. Also picks up the artifact-name slash fix from #1065, which is why the previous failed run could not even upload its codeception output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up humanmade/altis-dev-tools#1061 (Pass test theme through to WPLoader), which was merged to master after our previous run failed at WPLoader bootstrap. The rebased #1065 branch has both the WPLoader theme fix and the artifact-name / module-config additions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rmccue
left a comment
There was a problem hiding this comment.
Overall looking pretty good, although I spotted a few bugs with it, and some sharp edges for usability.
Spotted two blockers: uploading images directly to the Media Library seems to make them public, via the attachment page. Specifically:
- Open Media page
- Drag an image in to upload it
- Observe "Attachment Status: Private" and the lock icon in the list
- Open the image's modal, and open the "View attachment page" in a new window.
Additionally, the REST API seems to have the image available at /wp-json/wp/v2/media - both source_url and the description have the signed URL available, despite the UI displaying "Private".
This might be a UI bug rather than a data bug, as I think actually the image is meant to be public in this scenario?
I couldn't reproduce this. See attached video. private-media-1-hb.mp4But I did find a different bug: the link to the full-sized image is not encoded with the correct S3 bucket hostname. That bug (attachment page link) is now fixed. |
Tachyon's the_content filter only rewrites <img src>, not <a href>, so WordPress's attachment-page link (e.g. medium image wrapping a link to the full size) inherited the front-end host with a signature computed against the canonical S3 host and 403'd when clicked. Wrap the href in tachyon_url() via wp_get_attachment_link_attributes so the presign params survive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rivate-uploads-2 # Conflicts: # .github/workflows/ci.yml
- Import classes via `use` instead of fully-qualified references - Rename feature filters to `altis.media.private_media.*` namespace - US English: sanitisation → sanitization (file, namespace, functions) - Capitalisation fix: Signed_Urls → Signed_URLs - Function rename: is_private_media_active() → is_active() - UI copy: "Remove Override" → "Restore Default Visibility", "(forced)" → "(overridden)" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLI_Command::resolve_attachment_id() promoted to protected - Use absint() for IDs read from $_GET/$_POST in UI handlers - Register add_attachment hook at priority 0 so plugins can override - Import Altis\Global_Content for the global-site gate - Extract compute_automatic_visibility() so the modal can show the resolved auto value - "Force Public/Private" → "Public/Private" in the override dropdown (field label already says "Visibility Override") - "Automatic" → "Automatic (currently Public/Private)" - Move shared S3MockTrait require into the integration suite bootstrap Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Register the WP-CLI command inline in bootstrap_feature() via CLI_Command::class; delete the one-function cli.php file - Drop `wp private-media migrate` — the absent-meta rule (priority 6 in check_attachment_is_public) already treats pre-feature uploads as public, so first-time enable on a site with existing uploads needs no action. Docs updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bail out of bootstrap_feature() if S3_Uploads\Plugin isn't loaded; remove the scattered class_exists guards in visibility.php, ui.php and signed-urls.php that depended on it. The rest of the codebase can now assume the class exists. Rework the S3 ACL filter: rename `update_s3_acl` to `s3_acl`, take the ACL string as input rather than a null short-circuit slot, and treat an empty return as "skip the write". Lets consumers mutate the ACL value instead of only opting out. Test mock and docs follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the two row actions ("Publish image(s)" / "Unpublish
image(s)") in the post list table with one "Rescan attachment
visibility" action. The handler derives publish vs unpublish from
$post->post_status, so the visibility= URL param is gone.
The previous names were unclear in the post list — readers couldn't
tell what publishing or unpublishing an image meant in that context.
The combined action is just a manual trigger of the same lifecycle
that runs on publish/unpublish, useful when an import or filter
change bypassed the automatic transitions.
Docs updated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous disable_srcset_in_preview() returned an empty srcset when is_preview() or REST_REQUEST was true. That misses prefetched and embedded renders of the same image — the browser sees full srcset and tries to load size-specific URLs that the signature wasn't generated for. Rename to disable_srcset_for_private_attachments() and gate on Visibility\check_attachment_is_public( $attachment_id ) instead, so the behaviour is consistent regardless of how the image is rendered. The wp_calculate_image_srcset hook hands us the attachment ID, so no extra lookup is needed. Tests updated to drive the new signature directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set_attachment_visibility() now fires `altis.media.private_media.attachment_visibility_changed` after writing the ACL meta. Two default consumers are registered in Visibility\bootstrap(): update_s3_acl writes the bucket ACL, and the new purge_cdn_for_attachment delegates to Altis Cloud's purge_media_file_cache() (gated on CLOUDFRONT_DISTRIBUTION_ID so it's a no-op in dev/CI/non-Cloud installs). The previous purge_cdn_cache() function, its private_media/purge_cdn_cache filter, and its private_media/do_purge_cdn_cache action are removed — the action had no consumer in this codebase. Dropped the dead $cdn_purge_calls tracking from the test mock since nothing reads it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the stale "confirmation screen" step and screenshot from
the bulk-visibility docs — the bulk handler runs inline now, no
intermediate confirm page. Drop "Force" from the bulk action
labels too, matching the modal sidebar dropdown ("Public",
"Private", "Automatic"). Fix two stray "Remove Override"
references in docs left over from the row-action rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refresh the six private-media screenshots so they reflect the current strings: - Visibility column shows "Public (overridden)" / "Private (overridden)" instead of the older "Forced" wording - Row actions show "Restore Default Visibility" instead of "Remove Override" - Modal sidebar dropdown shows "Automatic (currently …)" plus plain "Public" / "Private" options - Post list row action collapsed to "Rescan attachment visibility" - Modal sidebar shot is tightly cropped on the relevant compat fields so the labels read clearly (the previous full-modal shot had wrapping issues) Also re-add the post-list screenshot reference in the docs that was dropped when the dual publish/unpublish actions were collapsed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Opt-in. To enable on a site, add the following to your
composer.json:{ "extra": { "altis": { "modules": { "media": { "private-media": true } } } } }The feature is always off on the Global Media Library site, even when enabled in configuration. The
wp private-media …commands are only registered while the feature is enabled.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) → public_altis_media_aclmeta (predates the feature) → public — safety net so enabling private media on a site with existing uploads doesn't silently break them before migrate runs.Overrides are stored in attachment metadata under
altis_override_visibility('public','private', or absent for automatic). The resolved state is persisted in_altis_media_aclpost meta (values'private'/'public-read', the literal S3 ACL strings) and 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's_altis_media_aclmeta flips topublic-readand its S3 ACL flips to match. The full ID list is stashed on the post inaltis_private_media_postmeta so unpublish can compare against it.privateand the S3 ACL follows.Storing the state in post meta rather than
post_statusmeans the parent post'stransition_post_statuscascade isn't re-fired on each touched attachment (which used to OOM on bulk publishes when srcset regen kicked in), and attachments stay at WP's defaultinheritstatus so the cap system, admin queries, and third-party plugins behave normally.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 Visibility: Force Public/Force Private/Automatic) handled inline viahandle_bulk_actions-upload, applying the chosen override to each selected attachment in the same request as the bulk-form submission (so WP core'sbulk-medianonce is the only nonce in play).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.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. The commands are only registered while the feature is enabled in configuration:wp private-media migrate [--dry-run]— one-time, run when first enabling on a site with existing content. Marks every existing attachment aslegacy_attachmentand records_altis_media_acl = public-readon it so 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/— 10 PHP source files (visibility, post_lifecycle, content_parser, sanitisation, signed_urls, site_icon, ui, class-cli-command, cli, namespace)assets/private-media.{js,css}— grid badges and modal sidebar wiringinc/namespace.phpandload.php— bootstrap integration;load.phpregisters theprivate-mediadefault (nowfalse).docs/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: 81 tests across 9 files in
tests/integration/PrivateMedia/:BootstrapTestVisibilityTestContentParserTestPostLifecycleTestSanitisationTest&normalisation, JSON-escaped URLs in block commentsOverrideTestBulkActionsTestSiteIconTestSignedUrlsTestRun 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. The suite requires the host project to enableprivate-media: truein itscomposer.json— the product-dev test environment does this.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 selected attachments flip to public and a "N attachment(s) updated" notice appears.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 the existing client 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.