diff --git a/classes/PodsAPI.php b/classes/PodsAPI.php index d0fcf790ec..2a9a854135 100644 --- a/classes/PodsAPI.php +++ b/classes/PodsAPI.php @@ -6071,6 +6071,13 @@ public function prepare_tableless_data_for_save( $pod, $field, $values, $pieces // Enforce integers / unique values for IDs $value_ids = []; + // Item ID for custom upload directory tag context (e.g. {@ID}). + $item_id = 0; + + if ( isset( $pieces['params'] ) && isset( $pieces['params']->id ) ) { + $item_id = (int) $pieces['params']->id; + } + // @todo Handle simple relationships eventually foreach ( $values as $v ) { if ( ! is_array( $v ) ) { @@ -6084,7 +6091,7 @@ public function prepare_tableless_data_for_save( $pod, $field, $values, $pieces // If file not found, add it if ( empty( $v ) ) { try { - $v = pods_attachment_import( $v ); + $v = pods_attachment_import( $v, $item_id, false, false, $field, $pod ); } catch ( Throwable $throwable ) { pods_debug_log( $throwable ); diff --git a/classes/fields/file.php b/classes/fields/file.php index ad5349f0ea..f4ba669a24 100644 --- a/classes/fields/file.php +++ b/classes/fields/file.php @@ -1289,61 +1289,37 @@ public function admin_ajax_upload() { if ( null === $custom_handler ) { - // Start custom directory. - $upload_dir = pods_v( $field['type'] . '_upload_dir', $field, 'wp' ); - - if ( 'wp' !== $upload_dir ) { - $custom_dir = pods_v( $field['type'] . '_upload_dir_custom', $field, '' ); - $context_pod = null; + $context_pod = null; - if ( $parent_post_id ) { - $context_pod = pods_get_instance( pods_v( 'name', $pod, false ), $parent_post_id ); + if ( $parent_post_id ) { + $context_pod = pods_get_instance( pods_v( 'name', $pod, false ), $parent_post_id ); - if ( ! $context_pod->exists() ) { - $context_pod = null; - } + if ( ! $context_pod->exists() ) { + $context_pod = null; } - - /** - * Filter the custom upload directory Pod context. - * - * @since 2.7.28 - * - * @param Pods $context_pod The Pods object of the associated pod for the post type. - * @param object $params The POSTed parameters for the request. - * @param array $field The field configuration associated to the upload field. - * @param array $pod The pod configuration associated to the upload field. - */ - $context_pod = apply_filters( 'pods_upload_dir_custom_context_pod', $context_pod, $params, $field, $pod ); - - $custom_dir = pods_evaluate_tags( $custom_dir, [ 'pod' => $context_pod ] ); - - /** - * Filter the custom Pod upload directory. - * - * @since 2.7.28 - * - * @param string $custom_dir The directory to use for the uploaded file. - * @param object $params The POSTed parameters for the request. - * @param Pods $context_pod The Pods object of the associated pod for the post type. - * @param array $field The field configuration associated to the upload field. - * @param array $pod The pod configuration associated to the upload field. - */ - $custom_dir = apply_filters( 'pods_upload_dir_custom', $custom_dir, $params, $context_pod, $field, $pod ); - - self::$tmp_upload_dir = $custom_dir; - - add_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); } + /** + * Filter the custom upload directory Pod context. + * + * @since 2.7.28 + * + * @param Pods $context_pod The Pods object of the associated pod for the post type. + * @param object $params The POSTed parameters for the request. + * @param array $field The field configuration associated to the upload field. + * @param array $pod The pod configuration associated to the upload field. + */ + $context_pod = apply_filters( 'pods_upload_dir_custom_context_pod', $context_pod, $params, $field, $pod ); + + // Start custom directory. + $is_custom_dir = static::use_custom_upload_dir( $field, $context_pod, $params, $pod ); + // Upload file. $attachment_id = media_handle_upload( 'Filedata', $parent_post_id ); // End custom directory. - if ( 'wp' !== $upload_dir ) { - remove_filter( 'upload_dir', [ $this, 'filter_upload_dir' ] ); - - self::$tmp_upload_dir = null; + if ( $is_custom_dir ) { + static::use_custom_upload_dir_teardown(); } if ( is_object( $attachment_id ) ) { @@ -1382,6 +1358,64 @@ public function admin_ajax_upload() { // KBAI! } + /** + * Prepare the custom upload directory for an upload. + * + * Sets the temporary upload directory and registers the upload_dir filter + * when the field is configured to use a custom directory. Call + * use_custom_upload_dir_teardown() after the upload completes. + * + * @since 3.4.0 + * + * @param array $field The field configuration associated to the upload field. + * @param Pods|null $context_pod The Pods object of the associated pod, or null if not available. + * @param object|array $params The upload parameters, for filter back-compat. + * @param array|null $pod The pod configuration, for filter back-compat. + * + * @return bool Whether a custom directory was set up. + */ + public static function use_custom_upload_dir( $field, $context_pod = null, $params = [], $pod = null ) { + $upload_dir = pods_v( $field['type'] . '_upload_dir', $field, 'wp' ); + + if ( 'wp' === $upload_dir ) { + return false; + } + + $custom_dir = pods_v( $field['type'] . '_upload_dir_custom', $field, '' ); + + $custom_dir = pods_evaluate_tags( $custom_dir, [ 'pod' => $context_pod ] ); + + /** + * Filter the custom Pod upload directory. + * + * @since 2.7.28 + * + * @param string $custom_dir The directory to use for the uploaded file. + * @param object $params The POSTed parameters for the request. + * @param Pods $context_pod The Pods object of the associated pod for the post type. + * @param array $field The field configuration associated to the upload field. + * @param array $pod The pod configuration associated to the upload field. + */ + $custom_dir = apply_filters( 'pods_upload_dir_custom', $custom_dir, $params, $context_pod, $field, $pod ); + + self::$tmp_upload_dir = $custom_dir; + + add_filter( 'upload_dir', [ self::class, 'filter_upload_dir' ] ); + + return true; + } + + /** + * Tear down the temporary custom upload directory setup. + * + * @since 3.4.0 + */ + public static function use_custom_upload_dir_teardown() { + remove_filter( 'upload_dir', [ self::class, 'filter_upload_dir' ] ); + + self::$tmp_upload_dir = null; + } + /** * Modify the upload directory. * @@ -1393,7 +1427,7 @@ public function admin_ajax_upload() { * * @return array The filtered uploads directory information. */ - public function filter_upload_dir( $uploads ) { + public static function filter_upload_dir( $uploads ) { if ( empty( self::$tmp_upload_dir ) ) { return $uploads; } diff --git a/includes/media.php b/includes/media.php index 0af6b214a6..5fca88090f 100644 --- a/includes/media.php +++ b/includes/media.php @@ -231,18 +231,38 @@ function pods_image_url( $image, $size = 'thumbnail', $default = 0, $force = fal * @param int $post_parent ID of post parent, default none. * @param boolean $featured Whether to set it as the featured (post thumbnail) of the post parent. * @param boolean $strict Whether to return errors upon failure. + * @param array $field Optional. The field configuration associated to the upload field. + * @param array $pod Optional. The pod configuration associated to the upload field. * * @return int Attachment ID. * * @since 2.3.0 */ -function pods_attachment_import( $url, $post_parent = null, $featured = false, $strict = false ) {// Only allow http(s). +function pods_attachment_import( $url, $post_parent = null, $featured = false, $strict = false, $field = null, $pod = null ) { + // Only allow http(s). $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) { return $strict ? (int) pods_error( __( 'Invalid file URL.', 'pods' ) ) : 0; } + $is_custom_dir = false; + + if ( $field ) { + $pod_name = pods_v( 'name', $pod, false ); + $context_pod = null; + + if ( $pod_name ) { + $context_pod = pods_get_instance( $pod_name, $post_parent ); + + if ( ! $context_pod->exists() ) { + $context_pod = null; + } + } + + $is_custom_dir = PodsField_File::use_custom_upload_dir( $field, $context_pod, [], $pod ); + } + require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; @@ -250,10 +270,14 @@ function pods_attachment_import( $url, $post_parent = null, $featured = false, $ $tmp = download_url( $url ); if ( is_wp_error( $tmp ) ) { + if ( $is_custom_dir ) { + PodsField_File::use_custom_upload_dir_teardown(); + } + return $strict ? (int) pods_error( $tmp->get_error_message() ) : 0; } - $file_array = [ + $file_array = [ 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ), 'tmp_name' => $tmp, ]; @@ -263,9 +287,18 @@ function pods_attachment_import( $url, $post_parent = null, $featured = false, $ if ( is_wp_error( $attachment_id ) ) { wp_delete_file( $tmp ); + if ( $is_custom_dir ) { + PodsField_File::use_custom_upload_dir_teardown(); + } + return $strict ? (int) pods_error( $attachment_id->get_error_message() ) : 0; } + // End custom directory now that all attachment file sizes have been generated. + if ( $is_custom_dir ) { + PodsField_File::use_custom_upload_dir_teardown(); + } + if ( 0 < $post_parent && $featured ) { update_post_meta( $post_parent, '_thumbnail_id', $attachment_id ); } diff --git a/tests/codeception/wpunit/functions/MediaTest.php b/tests/codeception/wpunit/functions/MediaTest.php new file mode 100644 index 0000000000..fabe105dcd --- /dev/null +++ b/tests/codeception/wpunit/functions/MediaTest.php @@ -0,0 +1,124 @@ +assertFileExists( $fixture, 'Test image fixture is missing.' ); + + $this->source_file = $fixture; + } + + public function tearDown(): void { + $this->source_file = null; + + parent::tearDown(); + } + + /** + * A file imported through a field configured with a custom upload directory + * should be stored under that directory and not the default yyyy/mm path. + */ + public function test_custom_upload_dir_used_for_import() { + $pod = [ + 'name' => 'test_media_pod', + 'type' => 'pod', + ]; + + $field = [ + 'type' => 'file', + 'file_upload_dir' => 'custom', + 'file_upload_dir_custom' => 'custom-test-dir', + ]; + + $attachment_id = pods_attachment_import( $this->source_file, 0, false, false, $field, $pod ); + + $this->assertIsInt( $attachment_id ); + $this->assertGreaterThan( 0, $attachment_id, 'Attachment import failed.' ); + + $file = get_attached_file( $attachment_id ); + + $this->assertIsString( $file ); + $this->assertStringContainsString( 'custom-test-dir', $file, 'Attachment was not stored in the custom upload directory.' ); + + // The main file should live outside the default date-based uploads subdir. + $default_subdir = current_time( 'Y/m' ); + + $this->assertStringNotContainsString( $default_subdir, $file, 'Attachment was stored in the default uploads subdir instead of the custom directory.' ); + + // Image sub-sizes must also land in the custom directory. + $metadata = wp_get_attachment_metadata( $attachment_id ); + + if ( ! empty( $metadata['sizes'] ) ) { + $base_dir = dirname( $file ); + + foreach ( $metadata['sizes'] as $size ) { + $this->assertStringContainsString( 'custom-test-dir', $base_dir . '/' . $size['file'], 'Image sub-size was not stored in the custom upload directory.' ); + } + } + + wp_delete_attachment( $attachment_id, true ); + } + + /** + * Default behavior is unchanged: importing without a custom directory uses + * the standard WordPress uploads path. + */ + public function test_default_wp_upload_dir_used_without_custom_config() { + $attachment_id = pods_attachment_import( $this->source_file ); + + $this->assertIsInt( $attachment_id ); + $this->assertGreaterThan( 0, $attachment_id, 'Attachment import failed.' ); + + $file = get_attached_file( $attachment_id ); + + $this->assertIsString( $file ); + $this->assertStringNotContainsString( 'custom-test-dir', $file ); + + wp_delete_attachment( $attachment_id, true ); + } + + /** + * A field still using the default 'wp' upload directory should not apply a + * custom directory even when a custom path value is present. + */ + public function test_wp_upload_dir_option_ignores_custom_path() { + $field = [ + 'type' => 'file', + 'file_upload_dir' => 'wp', + 'file_upload_dir_custom' => 'should-be-ignored', + ]; + + $attachment_id = pods_attachment_import( $this->source_file, 0, false, false, $field ); + + $this->assertIsInt( $attachment_id ); + $this->assertGreaterThan( 0, $attachment_id, 'Attachment import failed.' ); + + $file = get_attached_file( $attachment_id ); + + $this->assertIsString( $file ); + $this->assertStringNotContainsString( 'should-be-ignored', $file ); + + wp_delete_attachment( $attachment_id, true ); + } + +}