-
Notifications
You must be signed in to change notification settings - Fork 41
Derive the generated stylesheet's cache-busting version from its content #3242
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
base: master
Are you sure you want to change the base?
Changes from all commits
ba2ba74
303d693
62defb1
a377edf
506f5e5
828dc5b
f8d5e8d
a8ed9ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -407,20 +407,59 @@ public function get_color_settings() { | |
| */ | ||
| public function save_settings() { | ||
| $filename = FrmAppHelper::plugin_path() . '/css/custom_theme.css.php'; | ||
| update_option( 'frm_last_style_update', gmdate( 'njGi' ) ); | ||
|
|
||
| if ( ! is_file( $filename ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $this->clear_cache(); | ||
|
|
||
| $css = $this->get_css_content( $filename ); | ||
| $create_file = new FrmCreateFile( self::get_create_style_file_args() ); | ||
| $create_file->create_file( $css ); | ||
| $css = $this->get_css_content( $filename ); | ||
| $create_file = new FrmCreateFile( self::get_create_style_file_args() ); | ||
| $file_written = $create_file->create_file( $css ); | ||
|
|
||
| update_option( 'frmpro_css', $css, false ); | ||
| set_transient( 'frmpro_css', $css, MONTH_IN_SECONDS ); | ||
|
|
||
| if ( $file_written ) { | ||
| self::update_css_version( $css ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Store the cache-busting version for the generated stylesheet. | ||
| * | ||
| * The value is derived from the stylesheet contents rather than the clock, so it changes if | ||
| * and only if the generated bytes change. It is read back by | ||
| * FrmStylesController::get_css_version() and appended to the enqueued stylesheet URL. | ||
| * | ||
| * This previously used gmdate( 'njGi' ), which omitted the year and concatenated unpadded | ||
| * month, day and hour values. Distinct dates therefore produced identical version strings | ||
| * (1 Jan 10:59, 11 Jan 00:59 and 1 Nov 00:59 all produced "111059"), the value repeated | ||
| * every year, and two saves within the same minute were indistinguishable. Third-party CSS | ||
| * caches keyed on the enqueued URL could keep serving a copy generated from superseded | ||
| * content. | ||
| * | ||
| * Only called once FrmCreateFile::create_file() confirms the bytes reached disk, and only | ||
| * after the frmpro_css option and transient are stored, so the version never advertises | ||
| * content that is not being served. This matters because the value is content-derived and | ||
| * therefore idempotent: publishing a hash for a write that silently failed would pin a | ||
| * third-party cache to the superseded file permanently, since every later save of the same | ||
| * content would reproduce the same hash and the same URL. | ||
| * | ||
| * @since x.x | ||
| * | ||
| * @param string $css Generated stylesheet contents. | ||
| * | ||
| * @return void | ||
| */ | ||
| private static function update_css_version( $css ) { | ||
| // skipcq: PHP-A1004 -- md5() here is a content-derived cache-busting token, not a | ||
| // password hash. It must be deterministic so the same stylesheet always yields the | ||
| // same URL; password_hash() is salted and non-deterministic and would defeat the | ||
| // whole mechanism. Matches the existing md5()-for-cache-key calls in FrmAddon, | ||
| // FrmAntiSpam, FrmFormApi and FrmStyleApi. | ||
| update_option( 'frm_last_style_update', substr( md5( $css ), 0, 12 ) ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -297,6 +297,44 @@ public function test_migrate_to_97() { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Make sure migrate_to_107 is actually reached by the migration dispatch (migrate_data()) | ||
| * when upgrading from db_version 106, not just that the method works when called directly. | ||
| * A migration that is registered but never dispatched is the failure mode this guards | ||
| * against. | ||
| * | ||
| * This deliberately calls migrate_data() directly rather than the public upgrade(), because | ||
| * upgrade() unconditionally regenerates the default style at the end of every call | ||
| * ( $frm_style->update( 'default' ) ), which would overwrite frm_last_style_update with a | ||
| * fresh value regardless of whether migrate_to_107 actually ran, masking the result. | ||
| * | ||
| * @covers FrmMigrate::migrate_data | ||
| * @covers FrmMigrate::migrate_to_107 | ||
| */ | ||
| public function test_migrate_to_107_runs_on_dispatch_from_106() { | ||
| update_option( 'frm_last_style_update', '111059' ); | ||
|
|
||
| $frmdb = new FrmMigrate(); | ||
| $this->run_private_method( array( $frmdb, 'migrate_data' ), array( 106 ) ); | ||
|
|
||
| $this->assertFalse( get_option( 'frm_last_style_update' ), 'migrate_to_107 should have been dispatched and deleted the legacy frm_last_style_update option.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * A site already on db_version 107 (or later) must not have migrate_to_107 run again. | ||
| * | ||
| * @covers FrmMigrate::migrate_data | ||
| * @covers FrmMigrate::migrate_to_107 | ||
| */ | ||
| public function test_migrate_to_107_does_not_run_again_once_applied() { | ||
| update_option( 'frm_last_style_update', 'abcdef123456' ); | ||
|
|
||
| $frmdb = new FrmMigrate(); | ||
| $this->run_private_method( array( $frmdb, 'migrate_data' ), array( 107 ) ); | ||
|
|
||
| $this->assertSame( 'abcdef123456', get_option( 'frm_last_style_update' ), 'migrate_to_107 must not re-run once already at db_version 107.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * @covers FrmMigrate::collation | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @group misc | ||
| */ | ||
| class test_FrmCreateFile extends FrmUnitTest { | ||
|
|
||
| /** | ||
| * @var array<string> | ||
| */ | ||
| private $paths_to_clean_up = array(); | ||
|
|
||
| public function tearDown(): void { | ||
| foreach ( $this->paths_to_clean_up as $path ) { | ||
| if ( is_dir( $path ) ) { | ||
| @rmdir( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | ||
| } elseif ( file_exists( $path ) ) { | ||
| @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | ||
| } | ||
| } | ||
| $this->paths_to_clean_up = array(); | ||
|
|
||
| parent::tearDown(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * @covers FrmCreateFile::create_file | ||
| */ | ||
| public function test_create_file_returns_true_on_successful_write() { | ||
| $uploads = wp_upload_dir(); | ||
| $folder = 'frm-test-create-file-' . wp_generate_password( 8, false ); | ||
| $file_name = 'test.css'; | ||
|
|
||
| $create_file = new FrmCreateFile( | ||
| array( | ||
| 'file_name' => $file_name, | ||
| 'folder_name' => $folder, | ||
| ) | ||
| ); | ||
|
|
||
| $content = 'body{color:#123456}'; | ||
| $result = $create_file->create_file( $content ); | ||
| $written_path = $uploads['basedir'] . '/' . $folder . '/' . $file_name; | ||
| $this->paths_to_clean_up[] = $written_path; | ||
| $this->paths_to_clean_up[] = $uploads['basedir'] . '/' . $folder . '/index.php'; | ||
| $this->paths_to_clean_up[] = $uploads['basedir'] . '/' . $folder; | ||
|
|
||
| $this->assertTrue( $result, 'create_file() should return true when the file is actually written to disk.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| $this->assertSame( $content, file_get_contents( $written_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * The write-permission early return (`! $this->has_permission`) can only be reached in this | ||
| * harness by forcing the private flag directly: the local/direct filesystem method always | ||
| * succeeds here, and there is no practical, non-flaky way to make WP_Filesystem's credential | ||
| * check fail deterministically in an automated integration run. Reflection on the private | ||
| * property is used only for this one guard clause, not for the method under test itself. | ||
| * | ||
| * @covers FrmCreateFile::create_file | ||
| */ | ||
| public function test_create_file_returns_false_without_permission() { | ||
| $create_file = new FrmCreateFile( | ||
| array( | ||
| 'file_name' => 'no-permission.css', | ||
| 'folder_name' => 'frm-test-no-permission', | ||
| ) | ||
| ); | ||
|
|
||
| $permission_property = $this->get_accessible_property( $create_file, 'has_permission' ); | ||
| $permission_property->setValue( $create_file, false ); | ||
|
|
||
| $result = $create_file->create_file( 'body{color:#000}' ); | ||
|
|
||
| $this->assertFalse( $result, 'create_file() should return false when there is no filesystem permission.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * Forces the directory-creation early return (`! $dirs_exist`) with a real filesystem | ||
| * collision rather than a mock: a plain file is created where FrmCreateFile needs to create a | ||
| * directory of the same name, so both mkdir() and the is_dir() fallback genuinely fail. | ||
| * | ||
| * @covers FrmCreateFile::create_file | ||
| */ | ||
| public function test_create_file_returns_false_when_directory_cannot_be_created() { | ||
| $blocking = 'frm-test-blocking-' . wp_generate_password( 8, false ); | ||
| $blocked_path = wp_upload_dir()['basedir'] . '/' . $blocking; | ||
|
|
||
| $this->assertNotFalse( | ||
| file_put_contents( $blocked_path, 'not a directory' ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents | ||
| 'Test setup: failed to create the blocking file.' | ||
| ); | ||
| $this->paths_to_clean_up[] = $blocked_path; | ||
|
|
||
| $create_file = new FrmCreateFile( | ||
| array( | ||
| 'file_name' => 'blocked.css', | ||
| 'folder_name' => $blocking, | ||
| ) | ||
| ); | ||
|
|
||
| $result = $create_file->create_file( 'body{color:#000}' ); | ||
|
|
||
| $this->assertFalse( $result, 'create_file() should return false when its target directory cannot be created.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * The widened create_file() return type (void -> bool) must not affect its two internal | ||
| * callers: both discard the return value and remain void themselves. | ||
| * | ||
| * @covers FrmCreateFile::append_file | ||
| * @covers FrmCreateFile::combine_files | ||
| */ | ||
| public function test_append_file_and_combine_files_are_unaffected_by_the_widened_return_type() { | ||
| $uploads = wp_upload_dir(); | ||
| $folder = 'frm-test-callers-' . wp_generate_password( 8, false ); | ||
|
|
||
| $append_target = new FrmCreateFile( | ||
| array( | ||
| 'file_name' => 'append.css', | ||
| 'folder_name' => $folder, | ||
| ) | ||
| ); | ||
|
|
||
| $append_result = $append_target->append_file( 'first-part;' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| $this->assertNull( $append_result, 'append_file() must remain void regardless of create_file()\'s widened return type.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| $append_result_2 = $append_target->append_file( 'second-part;' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| $this->assertNull( $append_result_2 ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| $appended_path = $uploads['basedir'] . '/' . $folder . '/append.css'; | ||
| $this->paths_to_clean_up[] = $appended_path; | ||
| $this->paths_to_clean_up[] = $uploads['basedir'] . '/' . $folder . '/index.php'; | ||
| $this->paths_to_clean_up[] = $uploads['basedir'] . '/' . $folder; | ||
|
|
||
| $this->assertSame( 'first-part;second-part;', file_get_contents( $appended_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| $source_a = $uploads['basedir'] . '/' . $folder . '/source-a.css'; | ||
| $source_b = $uploads['basedir'] . '/' . $folder . '/source-b.css'; | ||
| file_put_contents( $source_a, 'a{color:#111}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents | ||
| file_put_contents( $source_b, 'b{color:#222}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents | ||
| $this->paths_to_clean_up[] = $source_a; | ||
| $this->paths_to_clean_up[] = $source_b; | ||
|
|
||
| $combine_target = new FrmCreateFile( | ||
| array( | ||
| 'file_name' => 'combined.css', | ||
| 'folder_name' => $folder, | ||
| ) | ||
| ); | ||
|
|
||
| $combine_result = $combine_target->combine_files( array( $source_a, $source_b ) ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| $this->assertNull( $combine_result, 'combine_files() must remain void regardless of create_file()\'s widened return type.' ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| $combined_path = $uploads['basedir'] . '/' . $folder . '/combined.css'; | ||
| $this->paths_to_clean_up[] = $combined_path; | ||
|
|
||
| // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents | ||
| $this->assertSame( "a{color:#111}\nb{color:#222}\n", file_get_contents( $combined_path ) ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
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.
Gating the version bump on
$file_writtenregresses cache-busting on any host whereFrmCreateFile::has_permissionis permanentlyfalse(WP_Filesystem needs FTP/SSH credentials that aren't configured —check_permission()sets this on construction and it never becomestrueagain for that request).On such a host,
create_file()always returnsfalse, so$file_writtenis alwaysfalse, soupdate_css_version()never runs —frm_last_style_updatestays deleted (postmigrate_to_107) andFrmStylesController::get_css_version()permanently falls back to the plugin version, which only changes on plugin releases.But
FrmStylesController::get_url_to_custom_style()already falls back toadmin-ajax.php?action=frmpro_csswhenever the static fileis_readable()check fails — exactly the case here — and that endpoint serves thefrmpro_cssoption/transient, whichsave_settings()still updates unconditionally on every save (FrmStyle.php:421-422, a few lines above this gate).enqueue_css()still appends$this_version(fromget_css_version()) to that AJAX URL viawp_register_style(), so the?ver=param is supposed to track content changes there too.Net effect: on these hosts, the actual served CSS (
frmpro_css) changes on every save, but the cache-busting version never advances, so any CDN/browser cache keyed on the enqueued URL serves stale CSS indefinitely. Before this PR, the version was unconditional (update_option( 'frm_last_style_update', gmdate( 'njGi' ) )ran regardless of write outcome), so this specific host class wasn't broken before — this is a new regression, not a pre-existing gap the PR merely doesn't fix.The write-failure gate is still correct for its intended case (a stale existing file being served with mismatched content after an intermittent write failure) — but it shouldn't apply when there's no static file being served at all, since the AJAX fallback stays in sync with
frmpro_cssregardless of write success. Consider gating on something closer to "would a stale file be served with content that doesn't match$css" rather than "did this specific write succeed" — e.g. only skip the version bump whencreate_file()fails and a readable static file already exists at the target path (the case that can actually go stale), not when there's no permission at all.