-
Notifications
You must be signed in to change notification settings - Fork 10.7k
Introduce the transient files engine #41055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Test Results SummaryCommit SHA: 3c76aa9
Please address the following issues prior to merging this pull request: |
70f1841 to
f3980df
Compare
0ecdce4 to
bc5d554
Compare
f848ccc to
a41c138
Compare
| $render_to_file = ! is_null( $metadata ); | ||
| $render_ok = false; | ||
|
|
||
| // phpcs:disable WordPress.WP.AlternativeFunctions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you might be able to do some, if not all, of the file operations in this method using WP_Filesystem, which is really just a wrapper around a lot of the same functions. The advantage is that is does some error handling for you, and handles some gotchas related to the quirks of varying file systems on different servers.
You just need to start by making sure the file system global is initialized, and then you can call its methods. Here are the ones that I think would pertain to render_template:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see that WP_Filesystem::mkdir doesn't have the $recursive argument, which is a problem since I need to create a directory like base/expiration. Also I see that WP_Filesystem::mkdir is just resorting to the PHP built-in mkdir after all.
Edit: If found this, though, which seems useful since it creates the new directory with the permissions of the parent directory: https://developer.wordpress.org/reference/functions/wp_mkdir_p/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for put_contents, I can't use it. I need to open the file and use the file handle in the callback that is passed to ob_start. And as for delete, for an empty directory it just does an rmdir; but for files I could use wp_delete_file (https://developer.wordpress.org/reference/functions/wp_delete_file/).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, instead of giving the output buffer a callback, you could just do something like this:
ob_start();
// output stuff
$content = ob_get_clean();
$wp_filesystem->put_contents( $file_path, $content );
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't know how big the rendered file will be (or put another way: we can figure it out by looking the file size of the template, but it could be arbitrarily large) so it's better to process the output in chunks (letting the ob_* engine handle buffering by itself). That's also why in TemplatingRestController::handle_parse_request I'm sending the rendered file in chunks of 1KByte.
| * @return array|null Rendered template information array, or null if no entry exists with the supplied id. | ||
| * @throws Exception The base directory for rendered files (possibly changed via filter) doesn't exist, or error deleting the file. | ||
| */ | ||
| private function get_rendered_file_core( string $sql_query, bool $include_metadata, bool $delete_if_expired ): ?array { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't look like this method checks to see if the file actually exists in the filesystem. What happens if there is a database entry for a file, but the file is missing (because of some external file operation or something)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the method has a bad name, it should rather be get_rendered_file_information: it just returns the (processed) database entry for the file, including the full path where the physical file is supposed to be located. It's the responsibility of the caller to ensure that the file actually exists, which is exactly what the handle_parse_request method does when the file is requested via the dedicated REST API endpoint.
Three endpoints are provided: POST /templates/render GET /templates/<id> (retrieves rendered file metadata) DELETE /templates/<id> Three new capabilities are created for these, and assigned to administrators and shop managers in WC_Install::create_roles: create_rendered_template read_rendered_template_info delete_rendered_template Also, the endpoint to retrieve rendered file contents has been moved from TemplatingEngine to this new class.
|
Hi all. What business initiative is this PR looking to support? Not able to find any links or anything into a "bigger picture" artifact. |
Blocks weren't very useful anyway since code inside blocks was being rendered at block definition time, not at block rendering time; reusable blocks can be created anyway by putting the relevant code+markup inside a variable and 'eval'-ing it.
| register_rest_route( | ||
| $this->route_namespace, | ||
| '/templates/(?P<id>[\d]+)', | ||
| array( | ||
| 'methods' => WP_REST_Server::READABLE, | ||
| 'callback' => fn( $request ) => $this->run( 'get_template', $request ), | ||
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_rendered_template_info' ), | ||
| 'args' => $this->get_args_for_get_or_delete_template(), | ||
| ) | ||
| ); | ||
|
|
||
| register_rest_route( | ||
| $this->route_namespace, | ||
| '/templates/(?P<id>[\d]+)', | ||
| array( | ||
| 'methods' => WP_REST_Server::DELETABLE, | ||
| 'callback' => fn( $request ) => $this->run( 'delete_template', $request ), | ||
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'delete_rendered_template' ), | ||
| 'args' => $this->get_args_for_get_or_delete_template(), | ||
| ) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since these have the same namespace and route, they can be combined into one register_rest_route call, like this:
| register_rest_route( | |
| $this->route_namespace, | |
| '/templates/(?P<id>[\d]+)', | |
| array( | |
| 'methods' => WP_REST_Server::READABLE, | |
| 'callback' => fn( $request ) => $this->run( 'get_template', $request ), | |
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_rendered_template_info' ), | |
| 'args' => $this->get_args_for_get_or_delete_template(), | |
| ) | |
| ); | |
| register_rest_route( | |
| $this->route_namespace, | |
| '/templates/(?P<id>[\d]+)', | |
| array( | |
| 'methods' => WP_REST_Server::DELETABLE, | |
| 'callback' => fn( $request ) => $this->run( 'delete_template', $request ), | |
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'delete_rendered_template' ), | |
| 'args' => $this->get_args_for_get_or_delete_template(), | |
| ) | |
| ); | |
| register_rest_route( | |
| $this->route_namespace, | |
| '/templates/(?P<id>[\d]+)', | |
| array( | |
| array( | |
| 'methods' => WP_REST_Server::READABLE, | |
| 'callback' => fn( $request ) => $this->run( 'get_template', $request ), | |
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_rendered_template_info' ), | |
| 'args' => $this->get_args_for_get_or_delete_template(), | |
| ), | |
| array( | |
| 'methods' => WP_REST_Server::DELETABLE, | |
| 'callback' => fn( $request ) => $this->run( 'delete_template', $request ), | |
| 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'delete_rendered_template' ), | |
| 'args' => $this->get_args_for_get_or_delete_template(), | |
| ), | |
| ) | |
| ); |
It would also be good to have a schema property for the route, like we do for other endpoints, e.g. Orders.
|
Hi @lkraav, the trigger for this project was the need to render receipts to be sent via public URL to a printing service from one of our mobile apps. We considered that implementing a generic solution would need about the same effort than implementing an ad-hoc solution for that specific use case. |
The action is scheduled to run immediately. After the cleanup happens, if there are more files pending deletion a new action is scheduled to run immediately, otherwise a new action is scheduled to run in 24h (but this can be adjusted with a new filter, woocommerce_delete_expired_rendered_template_files_interval). Also add two new debug tools, one to: (re)schedule the action and another one to unschedule it.
This endpoint serves the raw rendered file contents as the existing /wc/file/name endpoint does, except that it works even when the rendered file isn't marked as public. Also the 'read_rendered_template' capability is renamed to just 'read_rendered_template'.
ba7e51a to
2d43b65
Compare
Also some minor adjustments for the TemplatingEngine class.
Now the REST API endpoint always requires authentication but doesn't look at 'is_public' of the file, while the unauthenticated endpoint only looks at 'is_public' and not to authentication info.
REST endpoints have been renamed too.
1b557f2 to
90abddc
Compare
90abddc to
95660a1
Compare
Also add a mechanism for mocking the "exit" construct in LegacyProxy.
0ab6319 to
ef218ab
Compare
|
@coreymckrill Please ignore the failing unit tests for now (unless you know how to fix them of course!), these are due to the migration targetting WooCommerce 8.5 (they disappear if the migration targets 8.4 instead, but we're past the code freeze date for that version). |
|
Hi @coreymckrill, Apart from reviewing the code changes, please make sure to review the testing instructions as well. You can follow this guide to find out what good testing instructions should look like: |
|
Closed in favor of #42877 which is a simplified version. |
Changes proposed in this Pull Request:
This pull request introduces a transient files engine intended for registering files that have an expiration date and can be optionally served via a public, unauthenticated HTTP URL. Initially, the only mechanism provided to create these files is rendering them from a template file (available template files are part of the WooCommerce codebase, additional templates can be used via hook) consisting of a mix of literal text and PHP code; but additional mechanisms could be added in the future, such as directly providing binary content.
See the README file and the documentation comments in the TransientFilesEngine class for all the nitty-gritty details.
How to test the changes in this Pull Request:
Initial setup
Either perform a clean install of WooCommerce with this code applied, or perform the following manual steps in an already existing site:
TemplatingEngine::get_database_schema.wp eval "include WP_PLUGIN_DIR . '/woocommerce/includes/wc-update-functions.php'; wc_update_850_create_transient_files_directory();"wp eval flush_rewrite_rules());'wp eval 'WC_Install::create_roles();'Testing the code API
You will see HTML content in your console. If you want to see the content in a browser, append
> example.htmlto the command and open the file in the browser. The "source" (the template file with code and markup) is atsrc/TransientFiles/Templates/examples/show_variables.template.This time a file and a database entry have been created, and what you get is the random name assigned to the generated file. The file itself will be at
wp-content/uploads/woocommerce_transient_files/2034-01-01(yes, that's the expiration date) and a row will have been added to thewp_wc_transient_filestable.where
xxxxx...is the name of the file that you got in step 2. There's also aget_file_by_idmethod taking the database id.Now if you repeat step 4 with the same file name you'll get
null. Again, there's adelete_rendered_file_by_idmethod too.Testing the expired files deletion
To easily test the expired files deletion mechanism we need to be able to create transient files that have already expired. This is not possible out of the box, but can be achieved by using the expiration seconds filter with a negative value:
After adding that code to your WooCommerce install, go ahead and create 10 files that expired 24h ago by repeating the following command 10 times:
wp eval "echo wc_get_container()->get(\Automattic\WooCommerce\TransientFiles\TransientFilesEngine::class)->create_file_by_rendering_template('examples/show_variables', ['foo'=>'bar', 'fizz'=>'buzz'], ['expiration_seconds'=>-86400]);"The
wp_wc_transient_filestable should have 10 rows now (additionally to any rows created by your previous tests), and the/wordpress/wp-content/uploads/woocommerce-transient-filesdirectory should have a subdirectory that represents the date of yesterday and contains 10 files.Now run the following:
wp eval "var_export(wc_get_container()->get(\Automattic\WooCommerce\TransientFiles\TransientFilesEngine::class)->delete_expired_files(7));"The result will be:
and there will be only 3 files remaining, but the database table will be unchanged.
Running the same command again will yield the following results:
'files_deleted' => 3, 'rows_deleted' => 7: The directory representing the date of yesterday is gone, 3 rows remain in the database table.'files_deleted' => 0, 'rows_deleted' => 3: The database table is now empty.'files_deleted' => 0, 'rows_deleted' => 0: Nothing left to delete.You can also try the scheduled action by going to WooCommerce - Status - Tools and re-scheduling the cleanup:
By rescheduling the cleanup the action will be scheduled to keep running immediately as long as there's still data to delete, and then every 24h. You can change that interval with a filter, e.g. every 10 seconds:
Note however that the scheduled action deletes 1000 files every time, so for testing you'll need to either create as many transient files or tweak the code to use a smaller batch (pass the batch size to the
delete_expired_filescall inTransientFilesEngine::handle_expired_files_cleanup_action).Testing the rendering endpoint
Verify that you can access the rendered file in your browser or using any tool able to perform HTTP requests (replace
<filename>with the file name that you get in the previous step):http://localhost/wc/file/transient/<filename>.Repeat 8 and 9 but add an extra
'content-type' => 'text/plain'to the metadata array, next to'is_public'. This time the file will be served as plain text.Testing the JSON REST API endpoints
Assuming that your server allows basic authentication (use Postman or a similar tool with OAuth authentication instead otherwise):
Take note of the
file_namein the response and verify that you can render it in your browser:http://localhost/wc/file/transient/<filename>.Take note of the
idin the response and run the following, you'll get the same response as when you hit thewc/file/transientendpoint:Running it again will yield
{"deleted":false}instead of{"deleted":true}.is_publictofalse(or removing theis_publickey). Now repeating step 13 will still return the file contents, but step 12 will return a "not found" error.Changelog entry
Details
Significance
Type
Message
Comment