Skip to content

Add Private Media feature: attachments private by default#458

Open
mikelittle wants to merge 31 commits intomasterfrom
issue-162-default-private-uploads-2
Open

Add Private Media feature: attachments private by default#458
mikelittle wants to merge 31 commits intomasterfrom
issue-162-default-private-uploads-2

Conversation

@mikelittle
Copy link
Copy Markdown
Contributor

@mikelittle mikelittle commented Mar 11, 2026

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 from inc/private_media/namespace.php. Bootstrap is deferred until muplugins_loaded because some checks (get_site_meta, is_global_site) aren't available earlier.

Visibility resolution

visibility.php defines 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:

  1. Force-private override → private (absolute precedence)
  2. Force-public override → public
  3. Used in any published post → public
  4. Legacy attachment (legacy_attachment flag in metadata, set by the migration command) → public
  5. Site icon → public
  6. Default → private

Overrides 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_private filter calls it ~200x per page on a media-heavy view, which used to cause 30-second media library timeouts.

Post lifecycle

post_lifecycle.php hooks transition_post_status and save_post to track which attachments are referenced by which published posts:

  • On publish — scans the post content for attachment references via Content_Parser, plus the featured image, plus anything added by the private_media/post_attachment_ids filter. For each referenced attachment, calls add_post_reference() and re-evaluates its visibility. If it should now be public, the attachment's post_status flips to publish and its S3 ACL flips to public-read. The full ID list is stashed on the post in altis_private_media_post meta so unpublish can compare against it.
  • On unpublish/trash — reads the stashed ID list, removes each post→attachment reference, and re-evaluates each attachment. If nothing else references it (and there's no override), it flips back to private and the S3 ACL goes to private.
  • On edit — diffs the new attachment list against the stored one. Removed attachments lose their reference; new attachments gain one.

The status flip uses $wpdb->update() + clean_post_cache() directly rather than wp_update_post() because wp_update_post() re-fires transition_post_status on the attachment, which used to OOM on bulk publishes.

Content parser

content_parser.php extracts attachment IDs and URLs from post content. It handles:

  • Image blockswp:image {"id":N} and <img class="wp-image-N">
  • Gallery blockswp:gallery {"ids":[...]}
  • Cover blockswp:cover {"id":N} plus the background image URL
  • Media & Text blockswp:media-text {"mediaId":N}
  • Video blockswp:video {"id":N} and the <video> poster attribute
  • Audio blockswp:audio {"id":N}
  • File blockswp:file {"id":N} (for PDFs and downloadable files)
  • Naked URLs — anything inside the upload baseurl, resolved via attachment_url_to_postid()

extract_attachments_from_content() returns a deduplicated list of [attachment_id, modified_url] tuples. The private_media/post_attachment_ids filter lets sites add custom sources (gallery custom fields, ACF image lists, etc.).

Sanitisation

sanitisation.php hooks wp_insert_post_data to 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:

  • Slashed content (wp_unslash → process → wp_slash round-trip — wp_insert_post_data receives slashed input).
  • HTML-encoded &amp; between query params (normalises before splitting).
  • Both raw HTML attribute values and JSON-escaped (\/) forms in block comment attributes.

Signed URLs (preview / draft / file blocks)

signed_urls.php makes private images visible in contexts where they aren't yet "public" via the lifecycle:

  • Draft preview — hooks the_content (only when is_preview()). Walks attachments via Content_Parser, calls wp_get_attachment_url() (already returns a presigned URL for private attachments via S3 Uploads' filter), then for images routes through tachyon_url() so Tachyon receives the AWS params as top-level query params and replays the signed S3 fetch server-side.
  • REST API drafts — hooks rest_prepare_{post_type} and signs URLs in content.raw (the block editor reads from raw, not rendered, and the sanitiser will strip the params again on save).
  • Video postersreplace_private_poster_urls() does the same for <video poster="..."> attributes, looked up via attachment_url_to_postid().
  • PDF cover images — handled via the canonical-S3 rewrite escape (see below).
  • File blocks — block-comment JSON URLs are signed alongside HTML attribute URLs so Gutenberg reads signed URLs from the block attributes on reload.

Two important guards:

  1. disable_srcset_in_preview returns an empty wp_calculate_image_srcset in preview / REST contexts, because every srcset variant would need its own signature and responsive image switching can't pick the right one anyway.
  2. rewrite_presigned_url_to_canonical_s3 (filter on s3_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.php adds the visible bits:

  • Visibility column in list view — manage_media_columns / manage_media_custom_column. Renders one of four states: Private, Public, Public (forced), Private (forced).
  • Grid view badgeswp_prepare_attachment_for_js adds privateMediaOverride and privateMediaIsPublic to the JS attachment model; assets/private-media.css renders an absolute-positioned badge in each tile (lock icon for private, globe for forced public, no badge for naturally public).
  • Row actions — Make Public, Make Private, Remove Override (whichever apply, depending on current override state). Each is a nonced GET to upload.php?action=private_media_set_* handled by handle_row_actions().
  • Bulk actionSet Visibility redirects to admin.php?action=private_media_bulk_visibility which renders a confirmation form (target visibility + selected attachments) before applying.
  • Modal sidebarattachment_fields_to_edit adds an Attachment Status display, a Visibility Override dropdown, a Used In list of post links, and a Legacy flag if applicable. Edits save via attachment_fields_to_save and via an AJAX handler for in-modal changes.
  • Post row actionsPublish image(s) and Unpublish image(s) on the Posts/Pages list, which call handle_publish / handle_unpublish directly for that one post (useful for repairing a single post without running fix_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's image_downsize derives sub-size URLs by taking dirname() 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_src filter then 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) 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 on wp_get_attachment_image_src at 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, since wp_prepare_attachment_for_js builds those URLs through paths that don't all flow through wp_get_attachment_image_src.

The same code path handles video poster images. There's a related upstream bug filed against humanmade/s3-uploads requesting that get_s3_location_for_url recognise regional S3 URLs natively, after which this workaround can be removed.

Query compatibility

query_compat.php adds publish and private to the default post_status list for attachment queries via pre_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_cap filter grants read_post for private attachments to any user with upload_files (authors+). Without this, editors couldn't see private attachments in the media library because WP's default permissions block reads on private post statuses you don't own.

Site icon

site_icon.php automatically 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.php exposes 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 as legacy_attachment and flips its status to publish so it stays accessible. Iterates with proper forward pagination in dry-run mode (the bug where dry-run hung was fixed in bbde9b6).
  • 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_file postmeta.
  • 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 to post/page.

Configuration filters

Filter Purpose
private_media/allowed_post_types Post types whose content is scanned for attachment references
private_media/post_meta_attachment_keys Postmeta keys that contain attachment IDs (defaults: featured image)
private_media/post_attachment_ids Final attachment-ID list per post — add custom gallery/ACF sources here
private_media/update_s3_acl Test seam: short-circuit S3 ACL changes (return non-null)
private_media/purge_cdn_cache Test seam: short-circuit CDN purge (return non-null)
private_media/do_purge_cdn_cache Action fired when CDN cache should be purged for an attachment

Files

  • 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 wiring
  • inc/namespace.php and load.php — bootstrap integration
  • composer.json — feature flag default
  • 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: 79 tests across 8 files in tests/integration/PrivateMedia/:

File Tests Coverage
VisibilityTest 16 Priority resolution: overrides, used-in-published, legacy, site icon, default; cache invalidation
ContentParserTest 19 All block formats: image, gallery, cover, media-text, video, audio, file, naked URLs
PostLifecycleTest 13 publish/unpublish/trash/edit transitions, removed-attachment diffing, OOM-safe status flip
SanitisationTest 10 Slashed content, &amp; normalisation, JSON-escaped URLs in block comments
OverrideTest 7 Set/get/remove override, precedence, idempotence
QueryCompatTest 7 Attachment query post_status injection, feature-disabled compatibility
SiteIconTest 4 Site icon auto-public, force-private precedence
SignedUrlsTest 3 Preview signing, REST signing, srcset disable

Run with: composer dev-tools codecept run integration -p vendor/altis/media/tests/

Each test that touches S3 uses S3MockTrait to short-circuit private_media/update_s3_acl so the suite runs without an S3 client.

Acceptance: PrivateMediaCest (4 cases) in tests/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:

composer dev-tools codecept run acceptance -p vendor/altis/media/tests/

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)

  1. Upload an image, a video, an audio file, and a PDF. Confirm all four show up with lock badges in the grid view.
  2. Insert two of the images into a new post and publish it. Confirm those two attachments lose their lock badges (now naturally public) and the others remain private.
  3. Force one image public via the row action. Confirm it shows the globe badge and Public (forced) in list view.
  4. Force the PDF private via the row action. Confirm it stays private (in addition to being unused) and shows Private (forced) in list view.
  5. Unpublish the post from step 2. Confirm both images flip back to private and re-acquire lock badges.
  6. Re-publish, then bulk-select via list view → Set VisibilityForce Public. Confirm the confirmation screen lists the right files and applies cleanly.
  7. Run wp private-media migrate --dry-run then wp private-media migrate. Confirm dry-run completes (the previously-broken hang).
  8. Run 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.

  1. Direct presigned URL via CloudFrontcurl a presigned URL on the deployed domain. Should return 200. (Previously failing on platform-test.aws.hmn.md due to a CloudFront Host-header forwarding gap — verify the platform team's fix is in place by comparing against PlayStation production, where it works.)
  2. Draft preview of a post with private images — open a draft, click Preview, confirm images render via Tachyon presign params. Not 404, not empty body.
  3. Editor image insertion — insert a private image into a Gutenberg post, save, close, re-open. Image should still be visible (signed via content.raw in the REST response).
  4. Publish → S3 ACL flip — upload a fresh private image, embed it in a post, publish, then curl the direct S3 URL. Should return 200 public. Repeat for unpublish.
  5. PDF in published post — confirm PDFs become accessible on publish (PDFs aren't Tachyon-able, so this exercises the canonical-S3 path).
  6. Bulk publish OOM regression — publish a post that references many attachments. Confirm no memory blow-up (the wp_update_post → direct $wpdb->update fix).
  7. Stale metadata cache regression — edit an image's alt text or dimensions and reload the post. Confirm metadata reflects the update (the Cropper static-cache $unfiltered=true fix).
  8. Site icon — confirm the favicon stays accessible on all pages with the feature enabled.
  9. PDF / video grid rendering — upload a PDF and a video that has a poster image. Confirm both render with their cover/poster thumbnails and lock badges in both grid and list views. (This is the path covered by the recent 8c7dbcf cover-sub-size signing fix.)
  10. wp private-media migrate on a site with pre-existing uploads — run dry-run first, then for real. Confirm legacy uploads stay accessible after migration.
  11. wp private-media fix_attachments — run against a date range that includes recently-imported posts. Confirm the reference list and visibility are reconciled correctly.
  12. wp private-media set_visibility public <id> — set against a real S3 attachment and confirm the ACL flips.

mikelittle and others added 4 commits March 11, 2026 16:30
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>
mikelittle and others added 22 commits March 12, 2026 15:50
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 (&amp;) 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>
mikelittle and others added 2 commits April 7, 2026 17:30
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>
mikelittle and others added 2 commits April 8, 2026 17:09
- 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>
@mikelittle
Copy link
Copy Markdown
Contributor Author

It really is ready for a full review now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants