Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/Controller/Controller_Custom_Fonts.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use GFPDF\Helper\Helper_Data;
use GFPDF\Model\Model_Custom_Fonts;
use GFPDF\Model\Model_Font_Downloader;
use GFPDF\Model\Model_Font_Sources;
use GFPDF\Model\Model_Required_Fonts;
use GFPDF\Statics\Upload_Size;
use GFPDF_Vendor\GravityPdf\Upload\ErrorCode;
Expand Down Expand Up @@ -664,7 +665,7 @@ protected function move_fonts_to_font_dir( array $files ): array {
}

/**
* Save the font, adding a number to its name if the name is taken
* Save the font, adding a number to its name if the name is taken or is one a core font's copy could have
*
* The library claims the name as it writes, so a clash only shows up as its DESTINATION_EXISTS error
*
Expand All @@ -676,22 +677,39 @@ protected function upload_with_unique_name( File $file ): void {
$name = $file->getName();

for ( $attempt = 1; ; $attempt++ ) {
try {
$file->upload();
$reserved = $this->is_reserved_name( $file );

return;
} catch ( UploadException $e ) {
if ( $e->getErrorCode() !== ErrorCode::DESTINATION_EXISTS || $attempt === 10 ) {
throw $e;
if ( ! $reserved ) {
try {
$file->upload();

return;
} catch ( UploadException $e ) {
if ( $e->getErrorCode() !== ErrorCode::DESTINATION_EXISTS || $attempt >= 10 ) {
throw $e;
}
}
}

/* Leaves room for the dot, extension and number, which the library would otherwise cut off the end */
$base = mb_strcut( $name, 0, Filename::MAX_LENGTH - strlen( $file->getExtension() ) - 6, 'UTF-8' );
$file->setName( $base . wp_rand( 10000, 99999 ) );

/* Digits alone could complete a reserved name's hash */
$file->setName( $base . ( $reserved ? '_' . wp_rand( 1000, 9999 ) : wp_rand( 10000, 99999 ) ) );
}
}

/**
* Whether the name is one a core font's copy could have, which is used before the file at the font's own name
*
* @since 6.19.0
*/
protected function is_reserved_name( File $file ): bool {
$sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' );

return $sources instanceof Model_Font_Sources && $sources->is_reserved_name( $file->getNameWithExtension() );
}

/**
* Get a user-facing reason the font upload was refused
*
Expand Down
4 changes: 2 additions & 2 deletions src/Helper/Helper_Abstract_Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -1126,12 +1126,12 @@ public function settings_sanitize( $input = [] ) {
$gfpdf_options = $this->settings;

/* phpcs:ignore WordPress.Security.NonceVerification.Missing */
if ( empty( $_POST['_wp_http_referer'] ) || empty( $_POST['option_page'] ) || $_POST['option_page'] !== 'gfpdf_settings' ) {
if ( empty( $_POST['option_page'] ) || $_POST['option_page'] !== 'gfpdf_settings' ) {
return $input;
}

/* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per key below */
parse_str( wp_unslash( $_POST['_wp_http_referer'] ), $referrer );
parse_str( wp_unslash( $_POST['_wp_http_referer'] ?? '' ), $referrer );

$all_settings = $this->get_registered_fields();
$tab = sanitize_key( $referrer['tab'] ?? 'general' );
Expand Down
9 changes: 8 additions & 1 deletion src/Helper/Mpdf/On_Demand_Font_File_Finder.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public function findFontFile( $name ) {
}

try {
return parent::findFontFile( $name );
$found = parent::findFontFile( $name );
} catch ( MpdfException $e ) {
if ( ! $this->downloader->is_core_font_file( $name ) ) {
throw $e;
Expand All @@ -99,6 +99,13 @@ public function findFontFile( $name ) {

return $fallback;
}

/* Only a core font in the fonts folder can be another font's file, and a Font Manager record's stays the record's */
if ( $this->downloader->is_in_fonts_folder( $found ) && $this->downloader->is_core_font_file( $name ) && ! $this->downloader->is_record_file( $name ) && $this->downloader->download( $name ) ) {
return $this->downloader->get_font_path( $name );
}

return $found;
}

/**
Expand Down
169 changes: 146 additions & 23 deletions src/Model/Model_Font_Downloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,34 @@ protected function get_auto_install_constant(): ?bool {
}

/**
* Download a file on the manifest into the fonts folder, skipped if it's there
* Whether a Font Manager record of this site uses the file, so it isn't the core font's
*
* @since 6.19.0
*/
public function download( string $file ): bool {
public function is_record_file( string $file ): bool {
return $this->font_sources->is_record_file( $file );
}

/**
* Whether a path is in the fonts folder, as opposed to a bundled font's or one a filter added
*
* @since 6.19.0
*/
public function is_in_fonts_folder( string $path ): bool {
return untrailingslashit( dirname( $path ) ) === untrailingslashit( $this->data->template_font_location );
}

/**
* Download a file on the manifest into the fonts folder, or under its hashed name if another font's file has its name
*
* @param string $file The manifest file name
* @param bool $verify Hash a file of a known size too, to find another font's of the same size
*
* @return bool Whether the font's own copy is on disk now
*
* @since 6.19.0
*/
public function download( string $file, bool $verify = false ): bool {
$manifest = $this->font_sources->get_manifest();

if ( ! isset( $manifest[ $file ] ) ) {
Expand All @@ -287,8 +310,13 @@ public function download( string $file ): bool {
return false;
}

$font_path = $this->get_font_path( $file );
if ( is_file( $font_path ) ) {
if ( $this->font_sources->has_hashed_copy( $file ) ) {
return true;
}

$font_path = $this->font_sources->get_plain_path( $file );
$conflict = is_file( $font_path );
if ( $conflict && ! $this->is_conflict( $file, $verify && $this->is_auto_install_enabled( $file ) ) ) {
return true;
}

Expand All @@ -300,17 +328,90 @@ public function download( string $file ): bool {
return false;
}

if ( ! $this->save( $file, $font_path, $manifest[ $file ] ) ) {
$target = $conflict ? $this->font_sources->get_plain_path( $this->font_sources->get_hashed_name( $file ) ) : $font_path;
if ( ! $this->save( $file, $target, $manifest[ $file ] ) ) {
$this->record_failure( $file );

return false;
}

$this->clear_failure( $file );

if ( $conflict ) {
$this->log->notice( 'Font saved under a new name as another font has its name', [ 'name' => $file ] );
$this->remove_older_hashed_copies( $file, basename( $target ) );

/* The metrics and PDFs cached from the file that was there are stale */
FlushCache::flush();
}

return true;
}

/**
* Delete the copies a font's older releases left under other hashed names, if the name matches the copy's own SHA-1
*
* @param string $file The manifest file name
* @param string $keep The hashed name to keep
*
* @since 6.19.0
*/
protected function remove_older_hashed_copies( string $file, string $keep ): void {
$dir = $this->data->template_font_location;

foreach ( scandir( $dir ) ?: [] as $name ) {
$parsed = $this->font_sources->parse_hashed_name( $name );
if ( $name === $keep || $parsed === null || $parsed['file'] !== $file ) {
continue;
}

if ( strpos( $this->hash_blob( $dir . $name, (int) filesize( $dir . $name ) ), $parsed['sha'] ) === 0 ) {
wp_delete_file_from_directory( $dir . $name, $dir );
$this->log->notice( 'Font copy for an older release removed', [ 'name' => $name ] );
}
}
}

/**
* Whether the existing file at a manifest name is another font's: no known build has its size, or with $verify its
* SHA-1. A file that can't be read is left alone.
*
* @since 6.19.0
*/
protected function is_conflict( string $file, bool $verify ): bool {
if ( $this->font_sources->is_size_conflict( $file ) ) {
return true;
}

if ( ! $verify || $this->font_sources->get_hashed_name( $file ) === '' ) {
return false;
}

$path = $this->font_sources->get_plain_path( $file );
$size = (int) filesize( $path );
$sha = $this->hash_blob( $path, $size );

return $sha !== '' && ! in_array( $sha, $this->font_sources->get_known_builds( $file )[ $size ] ?? [], true );
}

/**
* Hash a font on disk, separate so tests can count the calls
*
* @since 6.19.0
*/
protected function hash_blob( string $path, int $size ): string {
return Git_Blob::sha1( $path, $size );
}

/**
* Whether the file's own copy is on disk, hashing the file at its name to be sure it's the manifest's
*
* @since 6.19.0
*/
protected function is_resolved( string $file ): bool {
return $this->font_sources->has_hashed_copy( $file ) || ( is_file( $this->font_sources->get_plain_path( $file ) ) && ! $this->is_conflict( $file, true ) );
}

/**
* Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose
* download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed.
Expand Down Expand Up @@ -343,7 +444,8 @@ public function prune( ?array $keep = null, int $since = 0 ): array {
continue;
}

$path = $this->get_font_path( $file );
/* The file at the legacy name, never a hashed copy, which isn't the installer's */
$path = $this->font_sources->get_plain_path( $file );
clearstatcache( true, $path );
if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) {
continue;
Expand Down Expand Up @@ -531,11 +633,17 @@ protected function get_font_row( string $key, string $label, string $type, array
* @since 6.19.0
*/
protected function get_core_font_row( string $key, array $family, array $labels ): ?array {
$on_disk = array_filter(
$family['files'],
static function ( string $file ) use ( $family ): bool {
return isset( $family['sizes'][ $file ] );
}
/* Named as on disk, so the sizes and the preview are the font's own copy */
$on_disk = array_map(
function ( string $file ): string {
return basename( $this->get_font_path( $file ) );
},
array_filter(
$family['files'],
static function ( string $file ) use ( $family ): bool {
return isset( $family['sizes'][ $file ] );
}
)
);

$can_download = $this->can_download_all( $family['files'] );
Expand Down Expand Up @@ -609,7 +717,8 @@ protected function get_size_fields( array $files, string $dir ): array {
}

/**
* Delete an unused downloaded family's files whatever their hash, keeping the licence files
* Delete an unused downloaded family's files whatever their hash, keeping the licence files. A file with a copy under
* its hashed name loses only the copy: the file at the name is another font's.
*
* @return array{id: string, removed: string[], bytes: int, font: array|null}|WP_Error `font` is its row now, if it can
* be installed again. 404 when
Expand Down Expand Up @@ -722,6 +831,9 @@ public function install_font( string $key ) {
protected function get_core_families(): array {
$families = [];

/* A Font Manager record's file at a family's name isn't the family's: its files have hashed names */
$collisions = $this->font_sources->get_record_collisions();

foreach ( $this->font_sources->get_sources() as $id => $source ) {
if ( empty( $source['manifest'] ) ) {
continue;
Expand All @@ -734,7 +846,8 @@ protected function get_core_families(): array {
continue;
}

$files = [];
$family = $this->font_sources->use_hashed_names( $family, $collisions );
$files = [];
foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) {
$file = $family[ $style ] ?? null;
if ( is_string( $file ) && isset( $manifest[ $file ] ) && $this->font_sources->is_core_font_file( $file ) ) {
Expand Down Expand Up @@ -957,7 +1070,13 @@ public function sync(): void {
array_map( [ $this, 'delete_back_off' ], $due );

$this->syncing = true;
$left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] );
$left = $this->within_budget(
array_merge( $due, $missing ),
function ( $file ) {
/* Verified here, off the render path, to find another font's file of a known size */
$this->download( (string) $file, true );
}
);
$this->syncing = false;

/* Includes the retries this run's first failures added */
Expand Down Expand Up @@ -1086,7 +1205,8 @@ protected function within_budget( array $items, callable $each ): array {
}

/**
* The files a paused run left, else the missing required files, less those that gave up
* The files a paused run left, else the required files, less those that gave up and those with a copy under their
* hashed name. A file already on disk is in the list so sync() can check it's the manifest's.
*
* @return string[]
*
Expand All @@ -1097,14 +1217,17 @@ protected function get_sync_files(): array {
$files = is_array( $remaining ) ? array_filter( $remaining, 'is_string' ) : $this->required_fonts->get_required_font_files();
$gave_up = $this->get_failed_files();

return array_values(
array_filter(
$files,
function ( $file ) use ( $gave_up ) {
return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->is_downloaded( $file );
}
)
$files = array_filter(
$files,
function ( $file ) use ( $gave_up ) {
return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->has_hashed_copy( $file );
}
);

$installed = array_filter( $files, [ $this->font_sources, 'is_installed' ] );

/* Those needing a download go first, so hashing the rest can't push one into a paused run */
return array_merge( array_diff( $files, $installed ), $installed );
}

/**
Expand All @@ -1127,7 +1250,7 @@ protected function get_retries(): array {

if ( $failure['retry_at'] > time() ) {
$next = $next === 0 ? $failure['retry_at'] : min( $next, $failure['retry_at'] );
} elseif ( $this->font_sources->is_downloaded( $file ) ) {
} elseif ( $this->is_resolved( $file ) ) {
$this->clear_failure( $file );
} else {
$due[] = $file;
Expand Down
Loading