LLMS_Certificates

Main LifterLMS Certificates “factory”


Description Description

Handles certificate generation and exports.

See also See also


Top ↑

Source Source

File: includes/class.llms.certificates.php

class LLMS_Certificates {

	use LLMS_Trait_Singleton,
		LLMS_Trait_Award_Default_Images;

	/**
	 * The ID for the award type.
	 *
	 * Used by {@see LLMS_Trait_Award_Default_Images}.
	 *
	 * @var string
	 */
	protected $award_type = 'certificate';

	/**
	 * Array of Certificate types.
	 *
	 * @var array
	 */
	public $certs = array();

	/**
	 * Array of local hosts
	 *
	 * @var string[]
	 */
	private $export_local_hosts;

	/**
	 * Array of hosts from which stylesheets won't be retrieved during the export
	 *
	 * @var string[]
	 */
	private $export_blocked_stylesheet_hosts;

	/**
	 * Array of hosts from which images won't be retrieved during the export
	 *
	 * @var string[]
	 */
	private $export_blocked_image_hosts;

	/**
	 * Constructor
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function __construct() {
		$this->init();
	}

	/**
	 * Initialize class.
	 *
	 * @since 1.0.0
	 * @since 4.21.0 Define useful class properties used when exporting.
	 * @since 6.0.0 Removed loading of class files that don't instantiate their class in favor of autoloading.
	 *
	 * @return void
	 */
	public function init() {

		$this->certs['LLMS_Certificate_User'] = isset( $this->certs['LLMS_Certificate_User'] ) ? $this->certs['LLMS_Certificate_User'] : include_once 'certificates/class.llms.certificate.user.php';

		$this->export_local_hosts = array_unique(
			array(
				wp_parse_url( get_home_url(), PHP_URL_HOST ),
				wp_parse_url( get_site_url(), PHP_URL_HOST ),
			)
		);

		$this->export_blocked_stylesheet_hosts = array_unique(
			/**
			 * Filters the blocked hosts for stylesheets in certificate exports
			 *
			 * @since 4.21.0
			 *
			 * @param string[] Array of hosts to block.
			 */
			apply_filters(
				'llms_certificate_export_blocked_stylesheet_hosts',
				array(
					'fonts.googleapis.com',
				)
			)
		);

		$this->export_blocked_image_hosts = array_unique(
			/**
			 * Filters the blocked hosts for images in certificate exports
			 *
			 * @since 4.21.0
			 *
			 * @param string[] Array of hosts to block.
			 */
			apply_filters(
				'llms_certificate_export_blocked_image_hosts',
				array()
			)
		);

	}

	/**
	 * Award a certificate to a user.
	 *
	 * Calls trigger method passing arguments
	 *
	 * @since 1.0.0
	 * @deprecated 6.0.0 `LLMS_Certificates::trigger_engagement()` is deprecated in favor of `LLMS_Engagement_Handler::handle_certificate()`.
	 *
	 * @param int $person_id       WP_User ID.
	 * @param int $certificate_id  WP_Post ID of the certificate template.
	 * @param int $related_post_id WP_Post ID of the related post, for example a lesson id.
	 * @return void
	 */
	public function trigger_engagement( $person_id, $certificate_id, $related_post_id ) {
		_deprecated_function( 'LLMS_Certificates::trigger_engagement()', '6.0.0', 'LLMS_Engagement_Handler::handle_certificate()' );
		LLMS_Engagement_Handler::handle_certificate( array( $person_id, $certificate_id, $related_post_id, null ) );
	}

	/**
	 * Generate a downloadable HTML file for a certificate
	 *
	 * @since 3.18.0
	 * @since 3.37.3 Added action `llms_certificate_generate_export`.
	 * @since 4.3.1 Introduce `llms_certificate_error` WP_Error code.
	 *
	 * @param string $filepath       Full path for the created file.
	 * @param int    $certificate_id WP_Post ID of the earned certificate.
	 * @return mixed WP_Error or full path to the generated export.
	 */
	private function generate_export( $filepath, $certificate_id ) {

		$html = $this->get_export_html( $certificate_id );

		if ( is_wp_error( $html ) ) {
			return $html;
		}

		/**
		 * Run actions prior to certificate export generation.
		 *
		 * @param string $filepath       Full path where the created file will be stored. Passed as a reference.
		 * @param string $html           Certificate HTML. Passed as a reference.
		 * @param int    $certificate_id WP_Post ID of the earned certificate.
		 */
		do_action_ref_array( 'llms_certificate_generate_export', array( &$filepath, &$html, $certificate_id ) );

		$file = fopen( $filepath, 'w' );
		if ( false === $file ) {
			return new WP_Error( 'llms_certificate_error', __( 'Unable to open export file (HTML certificate) for writing.', 'lifterlms' ) );
		}

		if ( false === fwrite( $file, $html ) ) {
			return new WP_Error( 'llms_certificate_error', __( 'Unable to write to export file (HTML certificate).', 'lifterlms' ) );
		}

		fclose( $file );

		return $filepath;

	}

	/**
	 * Retrieve an existing or generate a downloadable HTML file for a certificate
	 *
	 * @since 3.18.0
	 * @since 6.0.0 Use the certificate post title in favor of the deprecated meta value `_llms_certificate_title`.
	 *
	 * @param int  $certificate_id WP Post ID of the earned certificate.
	 * @param bool $use_cache      If true will check for existence of a cached version of the file first.
	 * @return mixed WP_Error or full path to the generated export.
	 */
	public function get_export( $certificate_id, $use_cache = false ) {

		if ( $use_cache ) {
			$cached = get_post_meta( $certificate_id, '_llms_export_filepath', true );
			if ( $cached && file_exists( $cached ) ) {
				return $cached;
			}
		}

		$cert = new LLMS_User_Certificate( $certificate_id );

		// Translators: %1$s = url-safe certificate title, %2$s = random alpha-numeric characters for filename obscurity.
		$filename  = sanitize_title( sprintf( esc_attr_x( 'certificate-%1$s-%2$s', 'certificate download filename', 'lifterlms' ), $cert->get( 'title' ), wp_generate_password( 12, false, false ) ) );
		$filename .= '.html';
		$filepath  = LLMS_TMP_DIR . $filename;

		// Generate the file.
		$filepath = $this->generate_export( $filepath, $certificate_id );

		if ( $use_cache && ! is_wp_error( $filepath ) ) {
			update_post_meta( $certificate_id, '_llms_export_filepath', $filepath );
		}

		return $filepath;

	}

	/**
	 * Retrieves the HTML of a certificate which can be used to create an exportable download
	 *
	 * @since 3.18.0
	 * @since 3.24.3 Unknown.
	 * @since 3.37.3 Refactored method into multiple functions.
	 * @since 4.3.1 If `$this->scrape_certificate()` generates a `WP_Error` early return it.
	 * @since 4.8.0 Remove redundant check for the presence of `DOMDocument`.
	 *
	 * @param int $certificate_id WP_Post ID of the earned certificate.
	 * @return WP_Error|string HTML of the certificate on success, otherwise an error object.
	 */
	private function get_export_html( $certificate_id ) {

		// Retrieve the raw HTML of the page.
		$html = $this->scrape_certificate( $certificate_id );
		if ( is_wp_error( $html ) ) {
			return $html;
		}

		// Modify the DOM.
		$html = $this->modify_dom( $html );

		/**
		 * Modify the HTML of a certificate export.
		 *
		 * @since  3.18.0
		 *
		 * @param string $html           HTML to be exported.
		 * @param int    $certificate_id WP_Post ID of the earned certificate.
		 */
		return apply_filters( 'llms_get_certificate_export_html', $html, $certificate_id );

	}

	/**
	 * Create a unique slug for earned certificates.
	 *
	 * When relying only on `wp_unique_post_slug()`, predictable URLs are created for earned certificates,
	 * such as "certificate-of-completion-1", "certificate-of-completion-2", etc... this method creates
	 * an obtuse and randomized suffix and appends it to the post slug.
	 *
	 * The unique suffix will be a randomized string at least 3 characters long and made up of lowercase letters and numbers.
	 *
	 * When ensuring uniqueness of the generated suffix, the length of the string will be increased by one for every 5
	 * encountered collisions.
	 *
	 * @since 6.0.0
	 *
	 * @param string $title The title of the certificate being created.
	 * @return string
	 */
	public function get_unique_slug( $title ) {

		$title = sanitize_title( $title ) . '-';

		/**
		 * Filters the minimum length of the suffix used to create a unique earned certificate slug.
		 *
		 * @since 6.0.0
		 *
		 * @param int $min_strlen The minimum desired suffix string length.
		 */
		$min_strlen = apply_filters( 'llms_certificate_unique_slug_suffix_min_length', 3 );

		$i = 0;
		do {
			$length = $min_strlen + floor( $i / 5 );
			$slug   = $title . strtolower( wp_generate_password( absint( $length ), false ) );
			$i++;
		} while ( wp_unique_post_slug( $slug, 0, 'publish', 'llms_my_certificate', 0 ) !== $slug );

		return $slug;

	}

	/**
	 * Modify the HTML using DOMDocument.
	 *
	 * Preparations include:
	 *
	 *     1. Removing all `script` tags.
	 *     2. Removes the WP Admin Bar.
	 *     3. Converting all stylesheets into inline `style` tags.
	 *     4. Removes all non stylesheet `link` tags.
	 *     5. Converts `img` tags into data uris.
	 *     6. Adds inline CSS to hide anything hidden in a print view.
	 *
	 * @since 3.37.3
	 * @since 3.38.1 Use `LLMS_Mime_Type_Extractor::from_file_path()` in place of `mime_content_type()` to avoid issues with PHP installs that do not support it.
	 * @since 4.8.0 Use `llms_get_dom_document()` in favor of loading `DOMDocument` directly.
	 * @since 4.21.0 Allow external assets (e.g. images/stylesheets from CDN) to be embedded/inlined.
	 *               Also, remove the WP Admin Bar earlier.
	 *               Move the links and images modification in specific methods.
	 *
	 * @param string $html Certificate HTML.
	 * @return string
	 */
	private function modify_dom( $html ) {

		$dom = llms_get_dom_document( $html );
		if ( is_wp_error( $dom ) ) {
			return $html;
		}

		// Don't throw or log warnings.
		$libxml_state = libxml_use_internal_errors( true );

		// Remove all <scripts>.
		$scripts = $dom->getElementsByTagName( 'script' );
		while ( $scripts && $scripts->length ) {
			$scripts->item( 0 )->parentNode->removeChild( $scripts->item( 0 ) );
		}

		// Remove the admin bar (if found).
		$admin_bar = $dom->getElementById( 'wpadminbar' );
		if ( $admin_bar ) {
			$admin_bar->parentNode->removeChild( $admin_bar ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		}

		$this->modify_dom_links( $dom );
		$this->modify_dom_images( $dom );

		// Hide print stuff (this is faster than traversing the dom to remove the element).
		$header = $dom->getElementsByTagName( 'head' )->item( 0 );
		$header->appendChild( $dom->createELement( 'style', '.no-print { display: none !important; }' ) );

		$html = $dom->saveHTML();

		// Handle errors.
		libxml_clear_errors();

		// Restore.
		libxml_use_internal_errors( $libxml_state );

		return $html;

	}

	/**
	 * Modify head's <link>s of the DOMDocument.
	 *
	 * @since 4.21.0
	 *
	 * @param DOMDocument $dom The DOMDocument containing the certificate.
	 * @return void
	 */
	private function modify_dom_links( $dom ) {

		// Get all <links>.
		$links      = $dom->getElementsByTagName( 'link' );
		$to_replace = array();

		// Inline stylesheets.
		foreach ( $links as $link ) {

			// Only proceed for stylesheets.
			if ( 'stylesheet' !== $link->getAttribute( 'rel' ) ) {
				continue;
			}

			$raw = $this->get_stylesheet_raw( $link->getAttribute( 'href' ) );

			if ( empty( $raw ) ) {
				continue;
			}

			// Add it to be inlined late.
			$tag          = $dom->createElement( 'style', $raw );
			$to_replace[] = array(
				'old' => $link,
				'new' => $tag,
			);

		}

		// Do replacements, ensures cascade order is retained.
		foreach ( $to_replace as $replacement ) {
			$replacement['old']->parentNode->replaceChild( $replacement['new'], $replacement['old'] );
		}

		// Remove all remaining non stylesheet <links>.
		$links = $dom->getElementsByTagName( 'link' );
		while ( $links && $links->length ) {
			$links->item( 0 )->parentNode->removeChild( $links->item( 0 ) );
		}

	}

	/**
	 * Get stylesheet raw content given its URL
	 *
	 * @since 4.21.0
	 *
	 * @param string  $stylesheet_href The stylesheet href.
	 * @param boolean $allowed_only    Optional. Get only stylesheet whose host is not in the `export_blocked_stylesheet_hosts` list.
	 * @return string|false
	 */
	private function get_stylesheet_raw( $stylesheet_href, $allowed_only = true ) {

		$href_host = wp_parse_url( $stylesheet_href, PHP_URL_HOST );

		// Only include stylesheets from non blocked hosts.
		if ( $allowed_only && in_array( $href_host, $this->export_blocked_stylesheet_hosts, true ) ) {
			return false;
		}

		// Get the actual CSS.
		if ( in_array( $href_host, $this->export_local_hosts, true ) ) { // Is local?
			$raw = file_get_contents( untrailingslashit( ABSPATH ) . wp_parse_url( $stylesheet_href, PHP_URL_PATH ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- getting a local file.
		} else {
			$response = wp_remote_get( $stylesheet_href );
			$raw      = wp_remote_retrieve_body( $response );
		}

		return $raw;

	}

	/**
	 * Modify images of the DOMDocument
	 *
	 * @since 4.21.0
	 *
	 * @param DOMDocument $dom The DOMDocument containing the certificate.
	 * @return void
	 */
	private function modify_dom_images( $dom ) {

		$images    = $dom->getElementsByTagName( 'img' );
		$to_remove = array();

		// Convert images to data uris.
		foreach ( $images as $img ) {

			$img_data_type = $this->get_image_data_and_type( $img->getAttribute( 'src' ) );

			if ( empty( $img_data_type['data'] ) || empty( $img_data_type['type'] ) ) {
				$to_remove[] = $img; // Save images to remove: removing them directly here will alter the collection iteration (skip).
				continue;
			}

			$data = base64_encode( $img_data_type['data'] );// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

			$img->setAttribute( 'src', 'data:' . $img_data_type['type'] . ';base64,' . $data );

			// Remove srcset and sizes attributes.
			$img->removeAttribute( 'sizes' );
			$img->removeAttribute( 'srcset' );
			// Remove useless loading attribute.
			$img->removeAttribute( 'loading' );
		}

		foreach ( $to_remove as $img ) {
			$img->parentNode->removeChild( $img ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		}

	}

	/**
	 * Get image data and type given its source URL
	 *
	 * @since 4.21.0
	 *
	 * @param string  $image_src    The image src.
	 * @param boolean $allowed_only Optional. Get only images whose host is not in the `export_blocked_image_hosts` list.
	 * @return array|false
	 */
	private function get_image_data_and_type( $image_src, $allowed_only = true ) {

		$src_host = wp_parse_url( $image_src, PHP_URL_HOST );

		// Only include images from non blocked hosts.
		if ( $allowed_only && in_array( $src_host, $this->export_blocked_image_hosts, true ) ) {
			return false;
		}

		if ( in_array( $src_host, $this->export_local_hosts, true ) ) { // Is local?
			$imgpath = untrailingslashit( ABSPATH ) . wp_parse_url( $image_src, PHP_URL_PATH );
			$data    = file_get_contents( $imgpath ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- getting a local file.
			$type    = LLMS_Mime_Type_Extractor::from_file_path( $imgpath );
		} else {
			$response = wp_remote_get( $image_src );
			$data     = wp_remote_retrieve_body( $response );
			$type     = wp_remote_retrieve_header( $response, 'content-type' );
		}

		return compact( 'data', 'type' );

	}

	/**
	 * Scrape a LifterLMS Certificate permalink and return the generated HTML.
	 *
	 * @since 3.37.3
	 *
	 * @param int $certificate_id WP_Post ID of the earned certificate (an "llms_my_certificate" post).
	 * @return WP_Error|string WP_Error on failure or the full page HTML on success.
	 */
	private function scrape_certificate( $certificate_id ) {

		// Create a nonce for getting the export HTML.
		$token = wp_generate_password( 32, false );
		update_post_meta( $certificate_id, '_llms_auth_nonce', $token );

		/**
		 * Modify the URL used to scrape the HTML of a certificate in preparation for a certificate export.
		 *
		 * @since 3.18.0
		 *
		 * @param string $url            Certificate permalink with a one-time use authorization token appended as a query string variable.
		 * @param int    $certificate_id WP_Post ID of the earned certificate (an "llms_my_certificate" post).
		 */
		$url = apply_filters(
			'llms_get_certificate_export_html_url',
			add_query_arg(
				'_llms_cert_auth',
				$token,
				get_permalink( $certificate_id )
			),
			$certificate_id
		);

		// Perform the request.
		$req = wp_safe_remote_get(
			$url,
			array(
				'sslverify' => false,
			)
		);

		// Delete the token after the request.
		delete_post_meta( $certificate_id, '_llms_auth_nonce', $token );

		// Error.
		if ( is_wp_error( $req ) ) {
			return $req;
		}

		return wp_remote_retrieve_body( $req );

	}

}

Top ↑

Methods Methods


Top ↑

Changelog Changelog

Changelog
Version Description
6.0.0 Changes:
5.3.0 Replace singleton code with LLMS_Trait_Singleton.
4.3.1 When generating the certificate to export, if $this->scrape_certificate() generates a WP_Error early, return it to avoid fatal errors.
4.21.0 Added new class properties: $export_local_hosts, $export_blocked_stylesheet_hosts, and $export_blocked_image_hosts.
3.38.1 Use LLMS_Mime_Type_Extractor::from_file_path() when retrieving the certificate's images mime types during html export.
3.37.3 Refactored get_export_html() method. Added an action llms_certificate_generate_export to allow modification of certificate exports before being stored on the server.
3.30.3 Explicitly define class properties.
1.0.0 Introduced.

Top ↑

User Contributed Notes User Contributed Notes

You must log in before being able to contribute a note or feedback.