Skip to content

Add Private Media feature: attachments private by default#458

Open
mikelittle wants to merge 53 commits into
masterfrom
issue-162-default-private-uploads-2
Open

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

Conversation

@mikelittle

@mikelittle mikelittle commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

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.

Update after review: ACL state was originally stored by repurposing the attachment's post_status; following review feedback it now lives in a dedicated _altis_media_acl post meta so attachments stay at WP's default inherit status and the feature leaves no runtime footprint when disabled. See commit 4601f74 for full rationale.

Update 2026-05-19: the default was flipped from true to false — the feature now ships off and must be explicitly enabled per site. See commit 881de23.

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. No _altis_media_acl meta (predates the feature) → public — safety net so enabling private media on a site with existing uploads doesn't silently break them before migrate runs.
  7. Default → private

Overrides are stored in attachment metadata under altis_override_visibility ('public', 'private', or absent for automatic). The resolved state is persisted in _altis_media_acl post 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_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 _altis_media_acl meta flips to public-read and its S3 ACL flips to match. 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), the meta flips back to private and the S3 ACL follows.
  • On edit — diffs the new attachment list against the stored one. Removed attachments lose their reference; new attachments gain one.

Storing the state in post meta rather than post_status means the parent post's transition_post_status cascade 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 default inherit status so the cap system, admin queries, and third-party plugins behave normally.

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 actions — three discrete actions (Set Visibility: Force Public / Force Private / Automatic) handled inline via handle_bulk_actions-upload, applying the chosen override to each selected attachment in the same request as the bulk-form submission (so WP core's bulk-media nonce is the only nonce in play).
  • 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.

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. 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 as legacy_attachment and records _altis_media_acl = public-read on it 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/ — 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 wiring
  • inc/namespace.php and load.php — bootstrap integration; load.php registers the private-media default (now false).
  • 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/:

File Tests Coverage
BootstrapTest 4 Opt-in gate resolution: empty config / explicit false / falsy values / explicit true
VisibilityTest 16 Priority resolution: overrides, used-in-published, legacy, site icon, pre-feature, 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
SanitisationTest 10 Slashed content, &amp; normalisation, JSON-escaped URLs in block comments
OverrideTest 7 Set/get/remove override, precedence, idempotence
BulkActionsTest 5 Three bulk actions (force public/private, remove override), unrelated-action passthrough, per-attachment cap check
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. The suite requires the host project to enable private-media: true in its composer.json — the product-dev test environment does this.

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 Visibility: Force Public. Confirm the selected attachments flip to public and a "N attachment(s) updated" notice appears.
  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 the existing client 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>
Comment thread inc/private_media/signed_urls.php Outdated
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>
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>
@mikelittle

Copy link
Copy Markdown
Contributor Author

@wisyhambolu I fixed the bulk action stuff. It was just wrong (Claude problem). Also added some tests to check the functionality too.

Comment thread inc/private_media/query-compat.php Outdated
@jerico

jerico commented May 14, 2026

Copy link
Copy Markdown
Contributor

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>
@mikelittle

Copy link
Copy Markdown
Contributor Author

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.

Gonna check this next.

@mikelittle

Copy link
Copy Markdown
Contributor Author

Most of it will work without Tachyon. And there is a check, function_exists( 'tachyon_url' ). But to switch out the use of Tachyon completely (it currently is assumed for previews) we will hit the issue where the S3 bucket is accessed from two different hostnames: internally in the code, vs with the external hostname. We would have to rewrite image URLs to the S3 bucket URLs, like we already do for other non-image URLs in preview.

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>
@mikelittle

Copy link
Copy Markdown
Contributor Author

Updated to handle the case where Tachyon is disabled.

mikelittle and others added 2 commits May 19, 2026 11:20
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>
mikelittle and others added 2 commits May 19, 2026 17:58
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 rmccue left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Open Media page
  2. Drag an image in to upload it
  3. Observe "Attachment Status: Private" and the lock icon in the list
  4. 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?

Comment thread .github/workflows/ci.yml Outdated
Comment thread docs/assets/private-media-modal-sidebar.png
Comment thread inc/private_media/class-cli-command.php Outdated
Comment thread inc/private_media/class-cli-command.php Outdated
Comment thread inc/private_media/class-cli-command.php Outdated
Comment thread tests/integration/PrivateMedia/BootstrapTest.php Outdated
Comment thread tests/integration/PrivateMedia/BulkActionsTest.php Outdated
Comment thread tests/integration/PrivateMedia/ContentParserTest.php Outdated
Comment thread tests/integration/PrivateMedia/OverrideTest.php Outdated
Comment thread tests/integration/PrivateMedia/OverrideTest.php Outdated
@mikelittle

mikelittle commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

Spotted two blockers: uploading images directly to the Media Library seems to make them public, via the attachment page. Specifically:

1. Open Media page
2. Drag an image in to upload it
3. Observe "Attachment Status: Private" and the lock icon in the list
4. Open the image's modal, and open the "View attachment page" in a new window.

I couldn't reproduce this. See attached video.

private-media-1-hb.mp4

But I did find a different bug: the link to the full-sized image is not encoded with the correct S3 bucket hostname.
Fixing that now.

That bug (attachment page link) is now fixed.

mikelittle and others added 11 commits May 25, 2026 16:57
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>
@mikelittle mikelittle requested a review from rmccue May 26, 2026 14:17
mikelittle and others added 2 commits May 26, 2026 15:37
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

4 participants