diff --git a/composer.json b/composer.json index 3eb8f49..832310f 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ ], "files": [ "inc/namespace.php", - "inc/global_assets/namespace.php" + "inc/global_assets/namespace.php", + "inc/private_uploads/namespace.php" ] }, "extra": { @@ -39,5 +40,10 @@ "humanmade/amf-wordpress" ] } + }, + "config": { + "allow-plugins": { + "composer/installers": false + } } } diff --git a/docs/private-uploads.md b/docs/private-uploads.md new file mode 100644 index 0000000..b7d7088 --- /dev/null +++ b/docs/private-uploads.md @@ -0,0 +1,73 @@ +# Private Uploads + +Private Uploads ensures that media files attached to unpublished posts are not publicly accessible via their S3 URLs. This is essential for sites handling embargoed or sensitive content such as financial results or pre-release announcements. + +## Configuration + +The feature is enabled by default. To disable it, set the following in your `composer.json`: + +```json +{ + "extra": { + "altis": { + "modules": { + "media": { + "private-uploads": false + } + } + } + } +} +``` + +## How It Works + +### Automatic Privacy + +Media privacy is determined automatically based on the parent post's status: + +- **Published parent post**: Attachments are **public** (accessible via direct S3 URL). +- **Draft, pending, or other non-published parent**: Attachments are **private** (accessible only via time-limited presigned URLs). +- **Unattached media** (no parent post): Defaults to **private**. +- **Global Media Library**: Always **public**, regardless of other settings. + +When a post is published, all its attachments are automatically updated to public. When a post is unpublished (moved back to draft, pending, etc.), its attachments are set to private. + +### Manual Override + +Each attachment has an optional privacy toggle available on the attachment edit screen: + +- **Auto** (default): Privacy follows the parent post status rules described above. +- **Private**: The file is always private, even if the parent post is published. +- **Public**: The file is always public, even if the parent post is unpublished. + +Attachments with a manual override are not affected by post status transitions. + +### Media Library + +A "Privacy" column in the media library list view shows the current effective privacy status of each attachment with a lock (private) or globe (public) icon. + +## Presigned URLs + +When an attachment is private, WordPress automatically serves presigned URLs instead of direct S3 URLs. These URLs are time-limited (6 hours by default) and grant temporary read access. This applies to: + +- `wp_get_attachment_url()` +- `wp_get_attachment_image_src()` +- Image srcsets + +The presigned URL expiry can be customised via the `s3_uploads_private_attachment_url_expiry` filter: + +```php +add_filter( 's3_uploads_private_attachment_url_expiry', function ( $expiry, $post_id ) { + return '+1 hour'; +}, 10, 2 ); +``` + +## Technical Details + +This feature works by integrating with the S3 Uploads plugin (`humanmade/s3-uploads`): + +- The `s3_uploads_is_attachment_private` filter determines whether an attachment should be private. +- S3 object ACLs are set to either `private` or `public-read` accordingly. +- Post status transitions trigger bulk ACL updates for all child attachments. +- Manual overrides are stored in the `_s3_privacy` post meta field. diff --git a/inc/namespace.php b/inc/namespace.php index 39a834b..d0722ff 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -28,6 +28,9 @@ function bootstrap() { // Set up global asset management. Global_Assets\bootstrap(); + + // Set up private uploads. + Private_Uploads\bootstrap(); } /** diff --git a/inc/private_uploads/namespace.php b/inc/private_uploads/namespace.php new file mode 100644 index 0000000..da3b121 --- /dev/null +++ b/inc/private_uploads/namespace.php @@ -0,0 +1,361 @@ +set_attachment_files_acl( $post_id, 'private' ); +} + +/** + * Determine whether an attachment should be private. + * + * Priority order: + * 1. Manual override via `_s3_privacy` post meta ('public' or 'private') + * 2. Legacy/pre-existing images (no `_s3_privacy` meta) are always public + * 3. Auto-managed images (`_s3_privacy` = 'auto'): + * a. Global media library attachments are always public + * b. Unattached media (post_parent = 0) defaults to private + * c. Based on parent post status: published = public, otherwise private + * + * @param bool $is_private Current private status. + * @param int $attachment_id The attachment post ID. + * @return bool Whether the attachment should be private. + */ +function is_attachment_private( bool $is_private, int $attachment_id ) : bool { + // 1. Check for manual override. + $privacy = get_post_meta( $attachment_id, '_s3_privacy', true ); + if ( $privacy === 'public' ) { + return false; + } + if ( $privacy === 'private' ) { + return true; + } + + // 2. Legacy/pre-existing images without privacy meta are always public. + // Only images explicitly enrolled in the feature (via 'auto' meta set + // during upload) are subject to automatic privacy management. + if ( $privacy !== 'auto' ) { + return false; + } + + // 3. Auto-managed: determine based on context. + // 3a. Global media library is always public. + if ( function_exists( 'Altis\\Global_Content\\is_global_site' ) && Global_Content\is_global_site() ) { + return false; + } + + // 3b. Unattached media defaults to private. + $attachment = get_post( $attachment_id ); + if ( ! $attachment || empty( $attachment->post_parent ) ) { + return true; + } + + // 3c. Based on parent post status. + $parent = get_post( $attachment->post_parent ); + if ( ! $parent ) { + return true; + } + + return $parent->post_status !== 'publish'; +} + +/** + * Handle post status transitions to update attachment ACLs. + * + * When a post is published, all non-manually-overridden attachments + * are set to public-read. When unpublished, they are set to private. + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param WP_Post $post The post object. + * @return void + */ +function handle_post_status_transition( string $new_status, string $old_status, WP_Post $post ) : void { + // Skip if status hasn't changed. + if ( $new_status === $old_status ) { + return; + } + + // Skip attachments and revisions. + if ( in_array( $post->post_type, [ 'attachment', 'revision' ], true ) ) { + return; + } + + // Skip global media library site. + if ( function_exists( 'Altis\\Global_Content\\is_global_site' ) && Global_Content\is_global_site() ) { + return; + } + + // Determine the target ACL based on transition direction. + $is_publishing = $new_status === 'publish' && $old_status !== 'publish'; + $is_unpublishing = $old_status === 'publish' && $new_status !== 'publish'; + + if ( ! $is_publishing && ! $is_unpublishing ) { + return; + } + + $acl = $is_publishing ? 'public-read' : 'private'; + + // Get all attachments for this post. + $attachments = get_posts( [ + 'post_type' => 'attachment', + 'post_parent' => $post->ID, + 'posts_per_page' => -1, + 'post_status' => 'any', + 'fields' => 'ids', + ] ); + + if ( empty( $attachments ) ) { + return; + } + + $plugin = S3_Plugin::get_instance(); + + foreach ( $attachments as $attachment_id ) { + // Only process auto-managed attachments. Skip legacy images + // (no meta) and those with manual overrides ('public'/'private'). + $privacy = get_post_meta( $attachment_id, '_s3_privacy', true ); + if ( $privacy !== 'auto' ) { + continue; + } + + $plugin->set_attachment_files_acl( $attachment_id, $acl ); + } +} + +/** + * Get the effective privacy status for an attachment. + * + * @param int $attachment_id The attachment post ID. + * @return string 'private' or 'public'. + */ +function get_privacy_status( int $attachment_id ) : string { + $is_private = is_attachment_private( false, $attachment_id ); + return $is_private ? 'private' : 'public'; +} + +/** + * Set the privacy value for an attachment. + * + * Pass 'auto' to enrol in automatic management, 'public' or 'private' + * for a manual override, or an empty string to remove the meta entirely + * (reverting to legacy/unmanaged public behaviour). + * + * @param int $attachment_id The attachment post ID. + * @param string $privacy 'auto', 'public', 'private', or '' to clear. + * @return void + */ +function set_manual_privacy( int $attachment_id, string $privacy ) : void { + if ( empty( $privacy ) ) { + delete_post_meta( $attachment_id, '_s3_privacy' ); + } else { + update_post_meta( $attachment_id, '_s3_privacy', $privacy ); + } + + // Update the S3 ACL to match. + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + return; + } + + $effective_status = get_privacy_status( $attachment_id ); + $acl = $effective_status === 'private' ? 'private' : 'public-read'; + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, $acl ); +} + +/** + * Register admin UI hooks for the private uploads feature. + * + * @return void + */ +function register_admin_hooks() : void { + // Media library list view columns. + add_filter( 'manage_media_columns', __NAMESPACE__ . '\\add_privacy_column' ); + add_action( 'manage_media_custom_column', __NAMESPACE__ . '\\render_privacy_column', 10, 2 ); + + // Attachment edit screen fields. + add_filter( 'attachment_fields_to_edit', __NAMESPACE__ . '\\add_privacy_field', 10, 2 ); + add_filter( 'attachment_fields_to_save', __NAMESPACE__ . '\\save_privacy_field', 10, 2 ); +} + +/** + * Add a Privacy column to the media library list view. + * + * @param array $columns Existing columns. + * @return array Modified columns. + */ +function add_privacy_column( array $columns ) : array { + $columns['s3_privacy'] = __( 'Privacy', 'altis' ); + return $columns; +} + +/** + * Render the Privacy column content in the media library list view. + * + * @param string $column_name The column being rendered. + * @param int $attachment_id The attachment post ID. + * @return void + */ +function render_privacy_column( string $column_name, int $attachment_id ) : void { + if ( $column_name !== 's3_privacy' ) { + return; + } + + $status = get_privacy_status( $attachment_id ); + + if ( $status === 'private' ) { + printf( + ' %s', + esc_attr__( 'Private', 'altis' ), + esc_html__( 'Private', 'altis' ) + ); + } else { + printf( + ' %s', + esc_attr__( 'Public', 'altis' ), + esc_html__( 'Public', 'altis' ) + ); + } +} + +/** + * Add a privacy field to the attachment edit form. + * + * @param array $form_fields Existing form fields. + * @param WP_Post $post The attachment post object. + * @return array Modified form fields. + */ +function add_privacy_field( array $form_fields, WP_Post $post ) : array { + $manual = get_post_meta( $post->ID, '_s3_privacy', true ); + $current = $manual ?: 'auto'; + + $options = [ + 'auto' => __( 'Auto (based on parent post status)', 'altis' ), + 'private' => __( 'Private', 'altis' ), + 'public' => __( 'Public', 'altis' ), + ]; + + $html = ''; + + $effective = get_privacy_status( $post->ID ); + $html .= sprintf( + '

%s: %s

', + esc_html__( 'Current status', 'altis' ), + esc_html( ucfirst( $effective ) ) + ); + + $form_fields['s3_privacy'] = [ + 'label' => __( 'Privacy', 'altis' ), + 'input' => 'html', + 'html' => $html, + 'helps' => __( 'Control whether this file is publicly accessible. "Auto" bases privacy on the parent post status.', 'altis' ), + ]; + + return $form_fields; +} + +/** + * Save the privacy field from the attachment edit form. + * + * @param array $post The post data array. + * @param array $attachment The attachment fields from the form. + * @return array The post data array. + */ +function save_privacy_field( array $post, array $attachment ) : array { + $privacy = $attachment['s3_privacy'] ?? ''; + + if ( ! in_array( $privacy, [ 'auto', 'private', 'public' ], true ) ) { + return $post; + } + + set_manual_privacy( (int) $post['ID'], $privacy ); + + return $post; +} diff --git a/load.php b/load.php index 2ab186c..efb4547 100644 --- a/load.php +++ b/load.php @@ -25,6 +25,7 @@ ], 'global-media-library' => false, 'local-media-library' => true, + 'private-uploads' => true, ]; $options = [ 'defaults' => $default_settings, diff --git a/tests/integration.suite.yml b/tests/integration.suite.yml new file mode 100644 index 0000000..b1f7c99 --- /dev/null +++ b/tests/integration.suite.yml @@ -0,0 +1,9 @@ +# Codeception Test Suite Configuration +# +# Suite for unit or integration tests that require WordPress functions and classes. + +actor: IntegrationTester +modules: + enabled: + - WPLoader + - \Helper\Integration diff --git a/tests/integration/private-uploads/PrivateUploadsAccessTest.php b/tests/integration/private-uploads/PrivateUploadsAccessTest.php new file mode 100644 index 0000000..17eb169 --- /dev/null +++ b/tests/integration/private-uploads/PrivateUploadsAccessTest.php @@ -0,0 +1,558 @@ +markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + if ( ! defined( 'S3_UPLOADS_BUCKET' ) ) { + $this->markTestSkipped( 'S3_UPLOADS_BUCKET not defined - S3 not configured.' ); + } + + if ( ! defined( 'TACHYON_URL' ) || ! TACHYON_URL ) { + $this->markTestSkipped( 'TACHYON_URL not defined.' ); + } + } + + /** + * Check whether the S3 server supports object-level ACLs. + * + * VersityGW returns 501 Not Implemented for GetObjectAcl. When object + * ACLs are not supported, tests that depend on per-object access control + * enforcement should be skipped. + * + * @return bool True if object ACLs are supported. + */ + private static function s3_supports_object_acls(): bool { + if ( self::$s3_supports_object_acls !== null ) { + return self::$s3_supports_object_acls; + } + + try { + $s3 = S3_Plugin::get_instance()->s3(); + $bucket = defined( 'S3_UPLOADS_BUCKET' ) ? S3_UPLOADS_BUCKET : ''; + // Strip any path prefix from the bucket name. + $bucket = explode( '/', $bucket )[0]; + + // Try to get the ACL of a non-existent object. Servers that + // support ACLs will return NoSuchKey; servers that don't + // (like VersityGW) return 501 Not Implemented. + $s3->getObjectAcl( [ + 'Bucket' => $bucket, + 'Key' => 'acl-support-test-' . uniqid() . '.txt', + ] ); + // If we get here without exception, ACLs are supported. + self::$s3_supports_object_acls = true; + } catch ( \Aws\S3\Exception\S3Exception $e ) { + $status_code = $e->getStatusCode(); + if ( $status_code === 501 ) { + self::$s3_supports_object_acls = false; + } else { + // Other errors (like 404 NoSuchKey) mean the API is supported. + self::$s3_supports_object_acls = true; + } + } catch ( \Exception $e ) { + // If we can't determine, assume not supported. + self::$s3_supports_object_acls = false; + } + + return self::$s3_supports_object_acls; + } + + /** + * Skip the current test if the S3 server does not support object ACLs. + * + * @return void + */ + private function requireObjectAclSupport(): void { + if ( ! self::s3_supports_object_acls() ) { + $this->markTestSkipped( 'S3 server does not support object ACLs (e.g. VersityGW).' ); + } + } + + /** + * Clean up all created posts and attachments after all tests. + * + * @return void + */ + public static function tearDownAfterClass(): void { + foreach ( self::$created_attachment_ids as $id ) { + wp_delete_attachment( $id, true ); + } + foreach ( self::$created_post_ids as $id ) { + wp_delete_post( $id, true ); + } + parent::tearDownAfterClass(); + } + + /** + * Create a real image file and upload it as an attachment. + * + * Uses wp_insert_attachment() and wp_generate_attachment_metadata() + * to go through the full upload pipeline including S3. + * + * @param int $post_parent Parent post ID. + * @return int Attachment ID. + */ + private function create_real_attachment( int $post_parent = 0 ): int { + // Create a real image file in a temp location. + $tmp_file = wp_tempnam( 'private-uploads-test-' ); + $image = imagecreatetruecolor( 100, 100 ); + $red = imagecolorallocate( $image, 255, 0, 0 ); + imagefill( $image, 0, 0, $red ); + imagejpeg( $image, $tmp_file, 90 ); + imagedestroy( $image ); + + // Build the upload array. + $filename = 'private-uploads-test-' . uniqid() . '.jpg'; + $upload = wp_upload_bits( $filename, null, file_get_contents( $tmp_file ) ); + unlink( $tmp_file ); + + if ( ! empty( $upload['error'] ) ) { + $this->fail( 'Failed to upload test file: ' . $upload['error'] ); + } + + $attachment_data = [ + 'post_title' => 'Private Uploads Test Image', + 'post_mime_type' => 'image/jpeg', + 'post_status' => 'inherit', + 'post_parent' => $post_parent, + ]; + + $attachment_id = wp_insert_attachment( $attachment_data, $upload['file'], $post_parent ); + $this->assertIsInt( $attachment_id ); + $this->assertGreaterThan( 0, $attachment_id ); + + // Generate metadata (this triggers S3 Uploads' ACL hook). + $metadata = wp_generate_attachment_metadata( $attachment_id, $upload['file'] ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + self::$created_attachment_ids[] = $attachment_id; + + return $attachment_id; + } + + /** + * Make an HTTP GET request and return the status code. + * + * Uses wp_remote_get with SSL verification disabled for local dev. + * + * @param string $url URL to request. + * @return int HTTP status code. + */ + private function get_http_status( string $url ): int { + $response = wp_remote_get( $url, [ + 'sslverify' => false, + 'timeout' => 15, + 'redirection' => 0, + // Don't send cookies - simulate anonymous access. + 'cookies' => [], + 'headers' => [], + ] ); + + if ( is_wp_error( $response ) ) { + $this->fail( 'HTTP request failed: ' . $response->get_error_message() . ' (URL: ' . $url . ')' ); + } + + return (int) wp_remote_retrieve_response_code( $response ); + } + + /** + * Extract the uploads-relative path from an attachment URL. + * + * Strips the base URL to get e.g. "2024/01/image.jpg". + * + * @param int $attachment_id Attachment ID. + * @return string Relative path within uploads. + */ + private function get_uploads_relative_path( int $attachment_id ): string { + $file = get_post_meta( $attachment_id, '_wp_attached_file', true ); + $this->assertNotEmpty( $file, 'Attachment should have _wp_attached_file meta.' ); + return $file; + } + + /** + * Build the direct uploads URL for an attachment. + * + * This is the /uploads/year/month/filename.jpg path that is proxied + * directly to S3 by Traefik/nginx. + * + * @param int $attachment_id Attachment ID. + * @return string Full URL. + */ + private function get_direct_url( int $attachment_id ): string { + $relative = $this->get_uploads_relative_path( $attachment_id ); + return home_url( '/uploads/' . $relative ); + } + + /** + * Build the Tachyon URL for an attachment WITHOUT presigned params. + * + * This simulates someone guessing the Tachyon URL structure. + * + * @param int $attachment_id Attachment ID. + * @return string Full Tachyon URL without presigned params. + */ + private function get_tachyon_url_without_presign( int $attachment_id ): string { + $relative = $this->get_uploads_relative_path( $attachment_id ); + return home_url( '/tachyon/' . $relative . '?w=100' ); + } + + /** + * Build the Tachyon URL for an attachment WITH presigned params. + * + * Goes through the full WordPress filter chain to get the correctly + * signed URL that Tachyon can use to access private S3 objects. + * + * @param int $attachment_id Attachment ID. + * @return string Full Tachyon URL with presigned params. + */ + private function get_tachyon_url_with_presign( int $attachment_id ): string { + // Get the presigned S3 URL via the WordPress filter chain. + $presigned_url = wp_get_attachment_url( $attachment_id ); + + // Pass it through tachyon_url() which extracts X-Amz-* params + // into the presign query arg. + if ( function_exists( 'tachyon_url' ) ) { + return tachyon_url( $presigned_url, [ 'w' => 100 ] ); + } + + // Fallback: manually construct a Tachyon URL. + return $presigned_url; + } + + /** + * Test: a public attachment is accessible via direct URL. + * + * Baseline test to confirm the test infrastructure works. + * + * @return void + */ + public function testPublicAttachmentAccessibleViaDirectUrl() { + $this->requireObjectAclSupport(); + + // Commit so the web server can see our data. + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + // Sanity check: this should be public. + $this->assertFalse( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on published post should be public.' + ); + + $direct_url = $this->get_direct_url( $attachment_id ); + $status = $this->get_http_status( $direct_url ); + + $this->assertEquals( + 200, + $status, + "Public attachment should be accessible via direct URL. URL: $direct_url" + ); + } + + /** + * Test: a public attachment is accessible via Tachyon. + * + * @return void + */ + public function testPublicAttachmentAccessibleViaTachyon() { + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + $tachyon_url = $this->get_tachyon_url_without_presign( $attachment_id ); + $status = $this->get_http_status( $tachyon_url ); + + $this->assertEquals( + 200, + $status, + "Public attachment should be accessible via Tachyon. URL: $tachyon_url" + ); + } + + /** + * Test: a private attachment is NOT accessible via direct URL. + * + * When the S3 object ACL is set to 'private', direct unauthenticated + * access via the /uploads/ path should be denied. + * + * @return void + */ + public function testPrivateAttachmentNotAccessibleViaDirectUrl() { + $this->requireObjectAclSupport(); + + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + // Confirm the attachment is private. + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on draft post should be private.' + ); + + // Explicitly set the ACL to private to be sure. + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, 'private' ); + + $direct_url = $this->get_direct_url( $attachment_id ); + $status = $this->get_http_status( $direct_url ); + + $this->assertContains( + $status, + [ 403, 404 ], + "Private attachment should NOT be accessible via direct URL (expected 403 or 404, got $status). URL: $direct_url" + ); + } + + /** + * Test: a private attachment is NOT accessible via Tachyon without presigned params. + * + * When someone constructs a Tachyon URL without the presign parameter, + * the Tachyon server should fail to access the private S3 object and + * return a 404. + * + * @return void + */ + public function testPrivateAttachmentNotAccessibleViaTachyonWithoutPresign() { + $this->requireObjectAclSupport(); + + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on draft post should be private.' + ); + + // Explicitly set the ACL to private. + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, 'private' ); + + $tachyon_url = $this->get_tachyon_url_without_presign( $attachment_id ); + $status = $this->get_http_status( $tachyon_url ); + + $this->assertContains( + $status, + [ 403, 404 ], + "Private attachment should NOT be accessible via Tachyon without presigned params (expected 403 or 404, got $status). URL: $tachyon_url" + ); + } + + /** + * Test: a private attachment IS accessible via Tachyon WITH presigned params. + * + * The WordPress URL chain should produce a Tachyon URL with a presign + * parameter that allows the Tachyon server to fetch the private S3 object. + * + * @return void + */ + public function testPrivateAttachmentAccessibleViaTachyonWithPresign() { + $this->requireObjectAclSupport(); + + if ( ! defined( 'TACHYON_SERVER_VERSION' ) || version_compare( TACHYON_SERVER_VERSION, '3.0.0', '<' ) ) { + $this->markTestSkipped( 'TACHYON_SERVER_VERSION must be >= 3.0.0 for presigned URL passthrough.' ); + } + + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on draft post should be private.' + ); + + // Explicitly set the ACL to private. + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, 'private' ); + + $tachyon_url = $this->get_tachyon_url_with_presign( $attachment_id ); + + $this->assertStringContainsString( + 'presign=', + $tachyon_url, + 'Tachyon URL for private attachment should contain presign parameter.' + ); + + $status = $this->get_http_status( $tachyon_url ); + + $this->assertEquals( + 200, + $status, + "Private attachment should be accessible via Tachyon WITH presigned params. URL: $tachyon_url" + ); + } + + /** + * Test: unattached media is not accessible via direct URL. + * + * @return void + */ + public function testUnattachedMediaNotAccessibleViaDirectUrl() { + $this->requireObjectAclSupport(); + + static::commit_transaction(); + + $attachment_id = $this->create_real_attachment( 0 ); + + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Unattached media should be private.' + ); + + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, 'private' ); + + $direct_url = $this->get_direct_url( $attachment_id ); + $status = $this->get_http_status( $direct_url ); + + $this->assertContains( + $status, + [ 403, 404 ], + "Unattached media should NOT be accessible via direct URL (expected 403 or 404, got $status). URL: $direct_url" + ); + } + + /** + * Test: after publishing a draft post, its attachment becomes publicly accessible. + * + * @return void + */ + public function testPublishingPostMakesAttachmentAccessible() { + $this->requireObjectAclSupport(); + + static::commit_transaction(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + self::$created_post_ids[] = $post_id; + + $attachment_id = $this->create_real_attachment( $post_id ); + + // Confirm private while draft. + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on draft post should be private.' + ); + + S3_Plugin::get_instance()->set_attachment_files_acl( $attachment_id, 'private' ); + + // Verify not accessible while private. + $direct_url = $this->get_direct_url( $attachment_id ); + $status_before = $this->get_http_status( $direct_url ); + + $this->assertContains( + $status_before, + [ 403, 404 ], + 'Attachment should NOT be accessible while post is draft.' + ); + + // Publish the post - this should trigger ACL update via transition_post_status. + wp_update_post( [ + 'ID' => $post_id, + 'post_status' => 'publish', + ] ); + + // Now the attachment should be public. + $this->assertFalse( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment should be public after post is published.' + ); + + $status_after = $this->get_http_status( $direct_url ); + + $this->assertEquals( + 200, + $status_after, + "Attachment should be accessible via direct URL after post is published. URL: $direct_url" + ); + } +} diff --git a/tests/integration/private-uploads/PrivateUploadsTest.php b/tests/integration/private-uploads/PrivateUploadsTest.php new file mode 100644 index 0000000..56ea861 --- /dev/null +++ b/tests/integration/private-uploads/PrivateUploadsTest.php @@ -0,0 +1,353 @@ +attachment->create( [ + 'post_parent' => 0, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertTrue( $result, 'Auto-managed unattached media should be private.' ); + } + + /** + * Test that auto-managed media attached to a draft post is private. + * + * @return void + */ + public function testAutoAttachmentOnDraftPostIsPrivate() { + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertTrue( $result, 'Auto-managed attachment on draft post should be private.' ); + } + + /** + * Test that auto-managed media attached to a published post is public. + * + * @return void + */ + public function testAutoAttachmentOnPublishedPostIsPublic() { + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertFalse( $result, 'Auto-managed attachment on published post should be public.' ); + } + + /** + * Test that legacy images (no _s3_privacy meta) are always public. + * + * Pre-existing images uploaded before the private uploads feature + * was enabled should remain unaffected and publicly accessible. + * + * @return void + */ + public function testLegacyUnattachedMediaIsPublic() { + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => 0, + ] ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertFalse( $result, 'Legacy unattached media (no _s3_privacy meta) should be public.' ); + } + + /** + * Test that legacy images on draft posts are still public. + * + * @return void + */ + public function testLegacyAttachmentOnDraftPostIsPublic() { + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertFalse( $result, 'Legacy attachment on draft post (no _s3_privacy meta) should be public.' ); + } + + /** + * Test that manual private override wins over published parent. + * + * @return void + */ + public function testManualPrivateOverridesParentPublished() { + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + update_post_meta( $attachment_id, '_s3_privacy', 'private' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertTrue( $result, 'Manual private override should win over published parent.' ); + } + + /** + * Test that manual public override wins over draft parent. + * + * @return void + */ + public function testManualPublicOverridesParentDraft() { + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + update_post_meta( $attachment_id, '_s3_privacy', 'public' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + $this->assertFalse( $result, 'Manual public override should win over draft parent.' ); + } + + /** + * Test that global site media is always public. + * + * @return void + */ + public function testGlobalSiteMediaAlwaysPublic() { + $site_id = \Altis\Global_Content\get_site_id(); + + if ( empty( $site_id ) ) { + $this->markTestSkipped( 'Global content site not available.' ); + } + + switch_to_blog( $site_id ); + + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => 0, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $result = Private_Uploads\is_attachment_private( false, $attachment_id ); + + restore_current_blog(); + + $this->assertFalse( $result, 'Global site media should always be public.' ); + } + + /** + * Test that publishing a post updates its attachment ACLs to public-read. + * + * This test mocks the S3 plugin to verify the correct method is called. + * + * @return void + */ + public function testPostPublishTransitionUpdatesAttachments() { + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + $this->markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $acl_calls = []; + add_action( 's3_uploads_set_attachment_files_acl', function ( $id, $acl ) use ( &$acl_calls ) { + $acl_calls[] = [ + 'attachment_id' => $id, + 'acl' => $acl, + ]; + }, 10, 2 ); + + // Simulate publish transition. + $post = get_post( $post_id ); + Private_Uploads\handle_post_status_transition( 'publish', 'draft', $post ); + + $this->assertNotEmpty( $acl_calls, 'ACL should be updated when post is published.' ); + $this->assertEquals( 'public-read', $acl_calls[0]['acl'], 'ACL should be set to public-read on publish.' ); + $this->assertEquals( $attachment_id, $acl_calls[0]['attachment_id'], 'Correct attachment should be updated.' ); + } + + /** + * Test that unpublishing a post updates its attachment ACLs to private. + * + * @return void + */ + public function testPostUnpublishTransitionUpdatesAttachments() { + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + $this->markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + $acl_calls = []; + add_action( 's3_uploads_set_attachment_files_acl', function ( $id, $acl ) use ( &$acl_calls ) { + $acl_calls[] = [ + 'attachment_id' => $id, + 'acl' => $acl, + ]; + }, 10, 2 ); + + // Simulate unpublish transition. + $post = get_post( $post_id ); + Private_Uploads\handle_post_status_transition( 'draft', 'publish', $post ); + + $this->assertNotEmpty( $acl_calls, 'ACL should be updated when post is unpublished.' ); + $this->assertEquals( 'private', $acl_calls[0]['acl'], 'ACL should be set to private on unpublish.' ); + } + + /** + * Test that post status transitions skip attachments with manual override. + * + * @return void + */ + public function testPostTransitionSkipsManualOverride() { + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + $this->markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + + // Create two attachments: one with manual override, one without. + $manual_attachment = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $manual_attachment, '_s3_privacy', 'private' ); + + $auto_attachment = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + update_post_meta( $auto_attachment, '_s3_privacy', 'auto' ); + + // Also create a legacy attachment (no _s3_privacy meta). + $legacy_attachment = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + $acl_calls = []; + add_action( 's3_uploads_set_attachment_files_acl', function ( $id, $acl ) use ( &$acl_calls ) { + $acl_calls[] = [ + 'attachment_id' => $id, + 'acl' => $acl, + ]; + }, 10, 2 ); + + // Simulate publish transition. + $post = get_post( $post_id ); + Private_Uploads\handle_post_status_transition( 'publish', 'draft', $post ); + + // Only the auto attachment should be updated. + $updated_ids = array_column( $acl_calls, 'attachment_id' ); + $this->assertContains( $auto_attachment, $updated_ids, 'Auto attachment should be updated.' ); + $this->assertNotContains( $manual_attachment, $updated_ids, 'Manual override attachment should be skipped.' ); + $this->assertNotContains( $legacy_attachment, $updated_ids, 'Legacy attachment (no _s3_privacy meta) should be skipped.' ); + } + + /** + * Test that when the feature is disabled, the filter returns default false. + * + * @return void + */ + public function testFeatureDisabledReturnsDefaultFalse() { + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => 0, + ] ); + + // When the filter is not hooked, calling with default false should stay false. + // Remove our filter temporarily. + remove_filter( 's3_uploads_is_attachment_private', 'Altis\\Media\\Private_Uploads\\is_attachment_private', 10 ); + + $result = apply_filters( 's3_uploads_is_attachment_private', false, $attachment_id ); + + // Re-add our filter. + add_filter( 's3_uploads_is_attachment_private', 'Altis\\Media\\Private_Uploads\\is_attachment_private', 10, 2 ); + + $this->assertFalse( $result, 'With feature disabled, default should be false.' ); + } + + /** + * Test that the privacy field exists in attachment edit form. + * + * @return void + */ + public function testPrivacyFieldExists() { + $attachment_id = self::factory()->attachment->create(); + $post = get_post( $attachment_id ); + + $fields = Private_Uploads\add_privacy_field( [], $post ); + + $this->assertArrayHasKey( 's3_privacy', $fields, 'Privacy field should exist in attachment form.' ); + $this->assertEquals( 'Privacy', $fields['s3_privacy']['label'], 'Field label should be "Privacy".' ); + } + + /** + * Test that saving the privacy field updates post meta. + * + * @return void + */ + public function testSavePrivacyFieldSetsPostMeta() { + $attachment_id = self::factory()->attachment->create(); + + $post_data = [ 'ID' => $attachment_id ]; + $attachment_data = [ 's3_privacy' => 'private' ]; + + // If S3 Uploads isn't available, we need to handle the ACL update gracefully. + Private_Uploads\save_privacy_field( $post_data, $attachment_data ); + + $meta = get_post_meta( $attachment_id, '_s3_privacy', true ); + $this->assertEquals( 'private', $meta, 'Privacy meta should be set to private.' ); + } +} diff --git a/tests/integration/private-uploads/PrivateUploadsUrlChainTest.php b/tests/integration/private-uploads/PrivateUploadsUrlChainTest.php new file mode 100644 index 0000000..6fe2707 --- /dev/null +++ b/tests/integration/private-uploads/PrivateUploadsUrlChainTest.php @@ -0,0 +1,304 @@ +markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + if ( ! has_filter( 'wp_get_attachment_url' ) ) { + $this->markTestSkipped( 'S3 Uploads is not active (presigning filter not registered).' ); + } + } + + /** + * Test that wp_get_attachment_url() includes presigned params for a private attachment. + * + * @return void + */ + public function testPresignedUrlForPrivateAttachment() { + $this->requireS3UploadsActive(); + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + // Factory-created attachments lack _wp_attached_file meta, which + // causes wp_get_attachment_url() to return ?attachment_id=N instead + // of an S3-style URL. Set it explicitly. + update_post_meta( $attachment_id, '_wp_attached_file', '2026/03/test-factory-image.jpg' ); + // Enrol in the private uploads feature. + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + // Confirm our filter marks this as private. + $this->assertTrue( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on draft post should be private.' + ); + + $url = wp_get_attachment_url( $attachment_id ); + + $this->assertStringContainsString( + 'X-Amz-Algorithm', + $url, + 'Private attachment URL should contain presigned X-Amz-Algorithm parameter.' + ); + $this->assertStringContainsString( + 'X-Amz-Signature', + $url, + 'Private attachment URL should contain presigned X-Amz-Signature parameter.' + ); + $this->assertStringContainsString( + 'X-Amz-Expires', + $url, + 'Private attachment URL should contain presigned X-Amz-Expires parameter.' + ); + } + + /** + * Test that wp_get_attachment_url() does NOT include presigned params for a public attachment. + * + * @return void + */ + public function testNoPresignedUrlForPublicAttachment() { + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + $this->markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'publish', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + // Set _wp_attached_file so wp_get_attachment_url() returns an S3-style URL. + update_post_meta( $attachment_id, '_wp_attached_file', '2026/03/test-factory-public.jpg' ); + // Enrol in the feature (auto on a published post = public). + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + // Confirm our filter marks this as public. + $this->assertFalse( + Private_Uploads\is_attachment_private( false, $attachment_id ), + 'Attachment on published post should be public.' + ); + + $url = wp_get_attachment_url( $attachment_id ); + + $this->assertStringNotContainsString( + 'X-Amz-Algorithm', + $url, + 'Public attachment URL should NOT contain presigned parameters.' + ); + } + + /** + * Test that tachyon_url() passes presigned params via the presign parameter. + * + * @return void + */ + public function testTachyonUrlIncludesPresignParam() { + if ( ! function_exists( 'tachyon_url' ) ) { + $this->markTestSkipped( 'Tachyon plugin not available.' ); + } + + if ( ! defined( 'TACHYON_URL' ) || ! TACHYON_URL ) { + $this->markTestSkipped( 'TACHYON_URL not defined.' ); + } + + if ( ! defined( 'TACHYON_SERVER_VERSION' ) || version_compare( TACHYON_SERVER_VERSION, '3.0.0', '<' ) ) { + $this->markTestSkipped( + 'TACHYON_SERVER_VERSION must be >= 3.0.0 for presigned URL passthrough. ' + . 'Current: ' . ( defined( 'TACHYON_SERVER_VERSION' ) ? TACHYON_SERVER_VERSION : 'not defined' ) + ); + } + + // Construct a fake S3 URL with presigned params, as tachyon_url() only + // looks at the URL string, not the database. + $upload_dir = wp_upload_dir(); + $s3_url = $upload_dir['baseurl'] . '/2024/01/test-image.jpg'; + $s3_url .= '?X-Amz-Algorithm=AWS4-HMAC-SHA256'; + $s3_url .= '&X-Amz-Credential=test'; + $s3_url .= '&X-Amz-Date=20240101T000000Z'; + $s3_url .= '&X-Amz-Expires=21600'; + $s3_url .= '&X-Amz-Signature=abc123'; + $s3_url .= '&X-Amz-SignedHeaders=host'; + + $tachyon = tachyon_url( $s3_url, [ 'w' => 800 ] ); + + $this->assertStringContainsString( + TACHYON_URL, + $tachyon, + 'URL should be rewritten to Tachyon URL.' + ); + $this->assertStringContainsString( + 'presign=', + $tachyon, + 'Tachyon URL should contain a presign parameter with the AWS signature.' + ); + $this->assertStringNotContainsString( + 'X-Amz-Algorithm', + parse_url( $tachyon, PHP_URL_QUERY ), + 'X-Amz-* params should be moved into the presign param, not left as separate query args.' + ); + } + + /** + * Test that tachyon_url() does NOT include presign param for public URLs (no X-Amz-* params). + * + * @return void + */ + public function testTachyonUrlNoPresignForPublicUrl() { + if ( ! function_exists( 'tachyon_url' ) ) { + $this->markTestSkipped( 'Tachyon plugin not available.' ); + } + + if ( ! defined( 'TACHYON_URL' ) || ! TACHYON_URL ) { + $this->markTestSkipped( 'TACHYON_URL not defined.' ); + } + + $upload_dir = wp_upload_dir(); + $s3_url = $upload_dir['baseurl'] . '/2024/01/test-image.jpg'; + + $tachyon = tachyon_url( $s3_url, [ 'w' => 800 ] ); + + $this->assertStringNotContainsString( + 'presign=', + $tachyon, + 'Public Tachyon URL should NOT contain a presign parameter.' + ); + } + + /** + * Test that the full chain works: private attachment → presigned S3 URL → Tachyon URL with presign. + * + * @return void + */ + public function testFullChainPrivateAttachmentToTachyonUrl() { + $this->requireS3UploadsActive(); + + if ( ! function_exists( 'tachyon_url' ) ) { + $this->markTestSkipped( 'Tachyon plugin not available.' ); + } + + if ( ! defined( 'TACHYON_URL' ) || ! TACHYON_URL ) { + $this->markTestSkipped( 'TACHYON_URL not defined.' ); + } + + if ( ! defined( 'TACHYON_SERVER_VERSION' ) || version_compare( TACHYON_SERVER_VERSION, '3.0.0', '<' ) ) { + $this->markTestSkipped( 'TACHYON_SERVER_VERSION must be >= 3.0.0.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + + // Set _wp_attached_file so wp_get_attachment_url() returns an S3-style URL. + update_post_meta( $attachment_id, '_wp_attached_file', '2026/03/test-factory-chain.jpg' ); + // Enrol in the private uploads feature. + update_post_meta( $attachment_id, '_s3_privacy', 'auto' ); + + // Get the presigned S3 URL. + $s3_url = wp_get_attachment_url( $attachment_id ); + + $this->assertStringContainsString( + 'X-Amz-Algorithm', + $s3_url, + 'S3 URL for private attachment should be presigned.' + ); + + // Pass through tachyon_url to simulate Tachyon rewriting. + $tachyon = tachyon_url( $s3_url, [ 'w' => 800 ] ); + + $this->assertStringContainsString( + TACHYON_URL, + $tachyon, + 'Should be rewritten to a Tachyon URL.' + ); + $this->assertStringContainsString( + 'presign=', + $tachyon, + 'Tachyon URL should carry presigned params via the presign query arg.' + ); + } + + /** + * Test that ACLs are set on all files after metadata is saved. + * + * @return void + */ + public function testAclSetAfterMetadataSave() { + if ( ! class_exists( 'S3_Uploads\\Plugin' ) ) { + $this->markTestSkipped( 'S3 Uploads plugin not available.' ); + } + + $post_id = self::factory()->post->create( [ + 'post_status' => 'draft', + ] ); + $attachment_id = self::factory()->attachment->create( [ + 'post_parent' => $post_id, + ] ); + // Note: set_acl_on_metadata_save() will set _s3_privacy to 'auto' + // automatically when metadata is saved, so no need to set it here. + + $acl_calls = []; + add_action( 's3_uploads_set_attachment_files_acl', function ( $id, $acl ) use ( &$acl_calls ) { + $acl_calls[] = [ + 'attachment_id' => $id, + 'acl' => $acl, + ]; + }, 10, 2 ); + + // Simulate metadata being saved (triggers our added_post_meta / updated_post_meta hook). + $metadata = [ 'width' => 100, 'height' => 100, 'file' => '2024/01/test.jpg' ]; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + // Our hook should have triggered set_attachment_files_acl. + $matching = array_filter( $acl_calls, function ( $call ) use ( $attachment_id ) { + return $call['attachment_id'] === $attachment_id && $call['acl'] === 'private'; + } ); + + $this->assertNotEmpty( + $matching, + 'set_attachment_files_acl should be called with "private" ACL after metadata save for a private attachment.' + ); + } +}