Skip to content

Commit

Permalink
Merge pull request #40 from tsmilan/EDAEASS-17236
Browse files Browse the repository at this point in the history
Update chrome-php to version 1.11 and Add PDF Generation Enhancements
  • Loading branch information
tsmilan authored Oct 10, 2024
2 parents 6b572e0 + 4b27d42 commit 9f788d5
Show file tree
Hide file tree
Showing 521 changed files with 36,569 additions and 17,747 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ on: [push, pull_request]

jobs:
ci:
uses: catalyst/catalyst-moodle-workflows/.github/workflows/ci.yml@main
uses: catalyst/catalyst-moodle-workflows/.github/workflows/ci.yml@main
secrets:
moodle_org_token: ${{ secrets.MOODLE_ORG_TOKEN }}
with:
min_php: 8.1
disable_behat: true
disable_grunt: true
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ This plugin will not work by itself without further development work and is inst
4. [Testing Conversion](#testing-conversion)
5. [License](#license)

## Branches

| Moodle version | Branch | PHP | Chrome-PHP |
|-------------------|-------------------|------|------------|
| Moodle 4.1+ | MOODLE_401_STABLE | 8.1+ | 1.11 |
| Moodle 3.9 - 4.1 | VERSION1 | 7.2+ | 0.9.0 |

## Requirements

This plugin requires the following:
- PHP 7.2+
- PHP 8.1+

## Installation

Expand Down Expand Up @@ -51,7 +58,7 @@ git clone git@github.com:catalyst/moodle-tool_pdfpages.git <moodledir>/admin/too

Use of the converter requires programmatic access, there in no frontend associated with this plugin, so you need to develop another module, or add this plugin to the dependencies of an existing Moodle plugin.

> Only users with the system level capability `tool/pdfpages:generatepdf` can conducted conversions, as this is required to create the single use access key for the headless browser session.
> Only users with capability `tool/pdfpages:generatepdf` in a specific context can conduct conversions, as this is required to create the single use access key for the headless browser session.
- Create a converter instance using the factory passing in a converter name (`chromium` or `wkhtmltopdf`) or you can leave it empty to grab the first enabled converter found (if no converters are configured correctly, an exception will be thrown):
```php
Expand All @@ -78,7 +85,7 @@ __Note__: if you didn't specify a filename when converting, you can obtain the f
In order to test how a URL will be converted and see the outcome, you can utilise the `/admin/tool/pdfpages/test.php` page in your browser.
This will utilise the configured converter on the server side to carry out the conversion, creating the converted file in the Moodle file system and then serve up the PDF to the browser.

In order to access this page, the logged in Moodle user needs to be an Admin or have a role with the capability `tool/pdfpages:generatepdf` at the system level.
In order to access this page, the logged in Moodle user needs to be an Admin or have a role with the capability `tool/pdfpages:generatepdf` in the relevant context.

This page takes the following query parameters:
- url: (required) the ASCII encoded target URL (may be absolute URL or relative Moodle URL)
Expand Down
78 changes: 77 additions & 1 deletion classes/converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
namespace tool_pdfpages;

use moodle_url;
use tool_pdfpages\pdf;

/**
* Interface for converting Moodle pages to PDFs.
Expand Down Expand Up @@ -75,7 +76,9 @@ final public function convert_moodle_url_to_pdf(moodle_url $url, string $filenam

$filename = ($filename === '') ? helper::get_moodle_url_pdf_filename($url) : $filename;
$key = key_manager::create_user_key_for_url($USER->id, $url);
$proxyurl = helper::get_proxy_url($url, $key);
$context = helper::get_page_context();
$contextid = is_null($context) ? null : $context->id;
$proxyurl = helper::get_proxy_url($url, $key, $contextid);
$content = $this->generate_pdf_content($proxyurl, $filename, $options, $cookiename, $cookievalue);

return $this->create_pdf_file($content, $filename);
Expand All @@ -89,6 +92,79 @@ final public function convert_moodle_url_to_pdf(moodle_url $url, string $filenam
}
}

/**
* Convert the given moodle URLs to PDF and store them in the file system.
* Note: If the currently logged in user does not have the correct capabilities to view the
* target URL, the created PDF will most likely be an error page.
*
* @param array $urls An array of moodle_url objects representing the target URLs to convert.
* @param string $filename the name to give converted file.
* (if none is specified, filename will be generated {@see \tool_pdfpages\helper::get_moodle_url_pdf_filename})
* @param array $options any additional options to pass to converter, valid options vary with converter
* instance, see relevant converter for further details.
* @param bool $keepsession should session be maintained after conversion? (For security reasons, this should always be `false`
* when conducting a conversion outside of a browser window, such as in an adhoc task or other background process, to prevent
* session hijacking.)
* @param string $cookiename cookie name to apply to conversion (optional).
* @param string $cookievalue cookie value to apply to conversion (optional).
* @param bool $printpagenumbers Whether to print page numbers in the footer of the combined PDF (optional,
* defaults to false).
*
* @return \stored_file the stored file created during conversion.
*/
final public function convert_moodle_urls_to_pdf(array $urls, string $filename = '', array $options = [],
bool $keepsession = false, string $cookiename = '', string $cookievalue = '',
bool $printpagenumbers = false): \stored_file {
global $USER;

$allurlsarevalid = empty(array_filter($urls, fn($url): bool => !$url instanceof moodle_url));
if (!$allurlsarevalid) {
throw new \coding_exception('All elements in the array must be an instance of moodle_url.');
}

try {
$options = $this->validate_options($options);
$pdffilepaths = [];

foreach ($urls as $url) {
$filename = ($filename === '') ? helper::get_moodle_url_pdf_filename($url) : $filename;
$key = key_manager::create_user_key_for_url($USER->id, $url);
$context = helper::get_page_context();
$contextid = is_null($context) ? null : $context->id;
$proxyurl = helper::get_proxy_url($url, $key, $contextid);
$pdfcontent = $this->generate_pdf_content($proxyurl, $filename, $options, $cookiename, $cookievalue);
$temppdf = $this->create_pdf_file($pdfcontent, $filename);
$pdffilepaths[] = $temppdf->copy_content_to_temp();
$temppdf->delete();
}

$tempdir = make_temp_directory('tool_pdfpages');
$outputfilepath = $tempdir . '/' . $filename;

$pdf = new pdf();
$pdf->combine_pdfs($pdffilepaths, $outputfilepath, $printpagenumbers);
$combinedpdfcontent = file_get_contents($outputfilepath);
unlink($outputfilepath);

// Return the combined PDF as a stored file.
return $this->create_pdf_file($combinedpdfcontent, $filename);
} catch (\Exception $exception) {
throw new \moodle_exception('error:urltopdf', 'tool_pdfpages', '', null, $exception->getMessage());
} finally {
// Clean up the temporary PDF files.
foreach ($pdffilepaths as $pdffilepath) {
if (file_exists($pdffilepath)) {
unlink($pdffilepath);
}
}

if (!$keepsession) {
// Make sure the access key token session cannot be used for any other requests, prevent session hijacking.
\core\session\manager::terminate_current();
}
}
}

/**
* Create a PDF file from content.
*
Expand Down
79 changes: 74 additions & 5 deletions classes/converter_chromium.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use HeadlessChromium\Browser;
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Cookies\Cookie;
use HeadlessChromium\Page;
use moodle_url;

defined('MOODLE_INTERNAL') || die();
Expand Down Expand Up @@ -58,6 +59,11 @@ class converter_chromium extends converter {
'marginRight' => '(float) margin right in inches',
'preferCSSPageSize' => '(bool) read params directly from @page',
'scale' => '(float) scale the page',
'windowSize' => '(array) The size of the browser window, e.g. [1920, 1080].',
'userAgent' => '(string) The custom user agent to use when navigating the page.',
'jsCondition' => '(string) A JavaScript condition to be evaluated, specified as a string.
It should return a boolean value indicating whether the condition has been met',
'jsConditionParams' => '(array) An array of parameters to pass to the Javascript function.',
];

/**
Expand All @@ -73,13 +79,19 @@ class converter_chromium extends converter {
* @return string raw PDF content of URL.
*/
protected function generate_pdf_content(moodle_url $proxyurl, string $filename = '', array $options = [],
string $cookiename = '', string $cookievalue = ''): string {
string $cookiename = '', string $cookievalue = ''): string {
try {
$browserfactory = new BrowserFactory(helper::get_config($this->get_name() . 'path'));
$browser = $browserfactory->createBrowser([
$browseroptions = [
'headless' => true,
'noSandbox' => true
]);
];

if (isset($options['windowSize'])) {
$browseroptions['windowSize'] = $options['windowSize'];
}

$browserfactory = new BrowserFactory(helper::get_config($this->get_name() . 'path'));
$browser = $browserfactory->createBrowser($browseroptions);

$page = $browser->createPage();
if (!empty($cookiename) && !empty($cookievalue)) {
Expand All @@ -91,10 +103,25 @@ protected function generate_pdf_content(moodle_url $proxyurl, string $filename =
])->await();
}

if (isset($options['userAgent'])) {
$page->setUserAgent($options['userAgent']);
}

$page->navigate($proxyurl->out(false))->waitForNavigation();
$pdf = $page->pdf($options);

$timeout = 1000 * helper::get_config($this->get_name() . 'responsetimeout');

$jscondition = isset($options['jsCondition']) ? $options['jsCondition'] : null;
$jsconditionparams = isset($options['jsConditionParams']) ? $options['jsConditionParams'] : [];
$this->wait_for_js_condition($page, $jscondition, $jsconditionparams, $timeout);

$pdfoptions = array_filter($options, function($option) {
$renderoptions = ['windowSize', 'userAgent', 'jsCondition', 'jsconditionparams'];
return !in_array($option, $renderoptions);
}, ARRAY_FILTER_USE_KEY);

$pdf = $page->pdf($pdfoptions);

return base64_decode($pdf->getBase64($timeout));
} finally {
// Always close the browser instance to ensure that chromium process is stopped.
Expand All @@ -104,6 +131,48 @@ protected function generate_pdf_content(moodle_url $proxyurl, string $filename =
}
}

/**
* Wait for a JavaScript condition on the page to be true, within a specified timeout.
*
* This function will repeatedly evaluate the provided JavaScript condition with a 0.5 second
* pause between checks, until it returns true or the timeout is exceeded.
*
* @param Page $page The current browser page.
* @param string|null $jscondition The JavaScript condition to be evaluated. This should be a function as a string,
* and should return a boolean value indicating whether the condition has been met.
* @param array $jsconditionparams An array of parameters to pass to the Javascript function.
* @param int $timeout The maximum time to wait for the MathJax to finish processing, in milliseconds.
* Defaults to 30000ms (30 seconds).
* @throws \moodle_exception If the JavaScript condition does not finish within the specified timeout.
*/
protected function wait_for_js_condition(Page $page, ?string $jscondition = null, array $jsconditionparams = [],
int $timeout = 30000): void {

if (empty($jscondition)) {
return;
}

$isconditionmet = false;
$starttime = microtime(true);

while (!$isconditionmet) {
$evaluation = $page->callFunction($jscondition, $jsconditionparams);

if ($evaluation->getReturnValue() === true) {
$isconditionmet = true;
} else {
// Wait 0.5 seconds before rechecking.
usleep(500000);

// Calculate elapsed time and check if timeout is exceeded.
$elapsedtime = (microtime(true) - $starttime) * 1000;
if ($elapsedtime >= $timeout) {
throw new \moodle_exception('error:mathjaxtimeout', 'tool_pdfpages');
}
}
}
}

/**
* Validate a list of options.
*
Expand Down
2 changes: 1 addition & 1 deletion classes/converter_wkhtmltopdf.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class converter_wkhtmltopdf extends converter {
* @return string raw PDF content of URL.
*/
protected function generate_pdf_content(moodle_url $proxyurl, string $filename = '', array $options = [],
string $cookiename = '', string $cookievalue = ''): string {
string $cookiename = '', string $cookievalue = ''): string {
$pdf = new Pdf(helper::get_config($this->get_name() . 'path'));
$pdf->setOptions($options);

Expand Down
47 changes: 45 additions & 2 deletions classes/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

namespace tool_pdfpages;

use context;
use Closure;
use file_storage;
use moodle_page;
use moodle_url;

defined('MOODLE_INTERNAL') || die();
Expand Down Expand Up @@ -126,15 +129,20 @@ public static function get_pdf_filerecord(string $filename, string $converter) :
*
* @param \moodle_url $targeturl the target URL to reach after passing through proxy.
* @param string $key the access key to use for Moodle user login validation.
* @param int|null $contextid the context ID to check for PDF generation capability.
*
* @return \moodle_url
*/
public static function get_proxy_url(moodle_url $targeturl, string $key) {
public static function get_proxy_url(moodle_url $targeturl, string $key, ?int $contextid = null) {
$params = [
'url' => $targeturl->out(),
'key' => $key,
'key' => $key
];

if (!is_null($contextid)) {
$params['contextid'] = $contextid;
}

return new moodle_url(self::PROXY_URL, $params);
}

Expand All @@ -148,4 +156,39 @@ public static function get_proxy_url(moodle_url $targeturl, string $key) {
public static function is_converter_enabled(string $convertername) {
return array_key_exists($convertername, converter_factory::get_converters());
}

/**
* Checks if the user has the capability to generate PDFs.
*
* @param int|null $contextid Optional context ID for a fallback check if the system-level
* and page context are not applicable.
*/
public static function check_generatepdf_capability(?int $contextid = null): void {
$context = \context_system::instance();
if (!has_capability('tool/pdfpages:generatepdf', $context)) {
$pagecontext = self::get_page_context();
if (!is_null($pagecontext)) {
$context = $pagecontext;
} else if (!is_null($contextid)) {
$context = \context::instance_by_id($contextid);
}
require_capability('tool/pdfpages:generatepdf', $context);
}
}

/**
* Obtains the page context via a Closure to avoid calling the magic method "magic_get_context",
* as it triggers a debugging coding problem for pages that don't have a context.
*
* @return context The page context.
*/
public static function get_page_context() {
global $PAGE;

return Closure::bind(
fn(moodle_page $page): ?context => $page->_context,
null,
$PAGE
)($PAGE);
}
}
2 changes: 1 addition & 1 deletion classes/key_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class key_manager {
* @throws \moodle_exception if user doesn't have permission to create key.
*/
protected static function create_user_key(int $userid, int $instance, string $iprestriction = ''): string {
require_capability('tool/pdfpages:generatepdf', \context_system::instance());
helper::check_generatepdf_capability();

$iprestriction = !empty($iprestriction) ? $iprestriction : null;

Expand Down
Loading

0 comments on commit 9f788d5

Please sign in to comment.