diff --git a/lib/Renderer.pm b/lib/Renderer.pm index 0cb1d6c3a..aaca5e2a0 100644 --- a/lib/Renderer.pm +++ b/lib/Renderer.pm @@ -1,30 +1,16 @@ package Renderer; use Mojo::Base 'Mojolicious'; -BEGIN { - use Mojo::File; - $main::libname = Mojo::File::curfile->dirname; - - # RENDER_ROOT is required for initializing conf files. - $ENV{RENDER_ROOT} = $main::libname->dirname - unless (defined($ENV{RENDER_ROOT})); - - # PG_ROOT is required for PG/lib/PGEnvironment.pm - $ENV{PG_ROOT} = $main::libname . '/PG'; +use Mojo::File; +use Env qw(RENDER_ROOT PG_ROOT baseURL); +use Date::Format; - # Used for reconstructing library paths from sym-links. - $ENV{OPL_DIRECTORY} = "$ENV{RENDER_ROOT}/webwork-open-problem-library"; - - $ENV{MOJO_CONFIG} = - (-r "$ENV{RENDER_ROOT}/renderer.conf") - ? "$ENV{RENDER_ROOT}/renderer.conf" - : "$ENV{RENDER_ROOT}/renderer.conf.dist"; - $ENV{MOJO_LOG_LEVEL} = 'debug'; +BEGIN { + # RENDER_ROOT and PG_ROOT are required for the WeBWorK::PG::Environment. + $RENDER_ROOT = Mojo::File::curfile->dirname->dirname; + $PG_ROOT = Mojo::File::curfile->dirname->child('PG'); } -use lib "$main::libname"; -print "using root directory: $ENV{RENDER_ROOT}\n"; - use Renderer::Model::Problem; use Renderer::Controller::IO; use WeBWorK::FormatRenderedProblem; @@ -32,18 +18,18 @@ use WeBWorK::FormatRenderedProblem; sub startup { my $self = shift; - # Merge environment variables with config file $self->plugin('Config'); - $self->plugin('TagHelpers'); $self->secrets($self->config('secrets')); - for (qw(problemJWTsecret webworkJWTsecret baseURL formURL SITE_HOST STRICT_JWT)) { - $ENV{$_} //= $self->config($_); - } - sanitizeHostURLs(); + $self->sanitizeHostURLs; + + # This is also required for the WeBWorK::PG::Environment, but is not needed at compile time. + $baseURL = $self->config->{baseURL}; - print "Renderer is based at $main::basehref\n"; - print "Problem attempts will be sent to $main::formURL\n"; + say 'Renderer is based at ' . $self->defaults->{baseHREF}; + say 'Problem attempts will be sent to ' . $self->defaults->{formURL}; + + $self->plugin('Renderer::Plugin::Assets'); # Handle optional CORS settings if (my $CORS_ORIGIN = $self->config('CORS_ORIGIN')) { @@ -63,8 +49,8 @@ sub startup { # Logging if ($ENV{MOJO_MODE} && $ENV{MOJO_MODE} eq 'production') { - my $logPath = "$ENV{RENDER_ROOT}/logs/error.log"; - print "[LOGS] Running in production mode, logging to $logPath\n"; + my $logPath = $self->home->child('logs', 'error.log'); + say "[LOGS] Running in production mode, logging to $logPath"; $self->log(Mojo::Log->new( path => $logPath, level => ($ENV{MOJO_LOG_LEVEL} || 'warn') @@ -72,33 +58,53 @@ sub startup { } if ($self->config('INTERACTION_LOG')) { - my $interactionLogPath = "$ENV{RENDER_ROOT}/logs/interactions.log"; - print "[LOGS] Saving interactions to $interactionLogPath\n"; + my $interactionLogPath = $self->home->child('logs', 'interactions.log'); + say "[LOGS] Saving interactions to $interactionLogPath"; my $resultsLog = Mojo::Log->new(path => $interactionLogPath, level => 'info'); $resultsLog->format(sub { my ($time, $level, @lines) = @_; my $start = shift(@lines); - my $msg = join ", ", @lines; - return sprintf "%s, %s, %s\n", $start, $time - $start, $msg; + return sprintf "%s, %s, %s\n", $start, $time - $start, join(', ', @lines); }); $self->helper(logAttempt => sub { shift; $resultsLog->info(@_); }); } + my $resourceUsageLog = Mojo::Log->new(path => $self->home->child('logs', 'resource_usage.log')); + $resourceUsageLog->format(sub { + my ($time, $level, @lines) = @_; + return '[' . time2str('%a %b %d %H:%M:%S %Y', time) . '] ' . join(', ', @lines) . "\n"; + }); + $self->helper(resourceUsageLog => sub { shift; return $resourceUsageLog->info(@_); }); + # Models - $self->helper(newProblem => sub { shift; Renderer::Model::Problem->new(@_) }); + $self->helper(newProblem => sub { my ($c, $args) = @_; Renderer::Model::Problem->new($c, $args) }); # Helpers - $self->helper(format => sub { WeBWorK::FormatRenderedProblem::formatRenderedProblem(@_) }); - $self->helper(validateRequest => sub { Renderer::Controller::IO::validate(@_) }); - $self->helper(parseRequest => sub { Renderer::Controller::Render::parseRequest(@_) }); - $self->helper(croak => sub { Renderer::Controller::Render::croak(@_) }); - $self->helper(logID => sub { shift->req->request_id }); - $self->helper(exception => sub { Renderer(@_) }); + $self->helper( + format => sub { + my ($c, $rh_result) = @_; + WeBWorK::FormatRenderedProblem::formatRenderedProblem($c, $rh_result); + } + ); + $self->helper(validateRequest => sub { my ($c, $options) = @_; Renderer::Controller::IO::validate($c, $options) }); + $self->helper(parseRequest => sub { my $c = shift; Renderer::Controller::Render::parseRequest($c) }); + $self->helper( + croak => sub { + my ($c, $exception, $depth) = @_; + Renderer::Controller::Render::croak($c, $exception, $depth); + } + ); + $self->helper(logID => sub { my $c = shift; $c->req->request_id }); + $self->helper( + exception => sub { + my ($c, $message, $status, %data) = @_; + Renderer::Controller::Render::exception($c, $message, $status, %data); + } + ); # Routes - # baseURL sets the root at which the renderer is listening, - # and is used in Environment for pg_root_url - my $r = $self->routes->under($ENV{baseURL}); + # baseURL is the root at which the renderer is listening. + my $r = $self->routes->under($self->config->{baseURL}); $r->any('/render-api')->to('render#problem'); $r->any('/render-ptx')->to('render#render_ptx'); @@ -108,10 +114,12 @@ sub startup { supplementalRoutes($r) if ($self->mode eq 'development' || $self->config('FULL_APP_INSECURE')); # Static file routes - $r->any('/pg_files/CAPA_Graphics/*static')->to('StaticFiles#CAPA_graphics_file'); - $r->any('/pg_files/tmp/*static')->to('StaticFiles#temp_file'); - $r->any('/pg_files/*static')->to('StaticFiles#pg_file'); - $r->any('/*static')->to('StaticFiles#public_file'); + $r->any('/pg_files/CAPA_Graphics/*static')->to('StaticFiles#CAPA_graphics_file')->name('capaFile'); + $r->any('/pg_files/tmp/*static')->to('StaticFiles#temp_file')->name('pgTempFile'); + $r->any('/pg_files/*static')->to('StaticFiles#pg_file')->name('pgFile'); + $r->any('/*static')->to('StaticFiles#public_file')->name('publicFile'); + + return; } sub supplementalRoutes { @@ -156,45 +164,40 @@ sub timeout { } sub sanitizeHostURLs { - $ENV{SITE_HOST} =~ s!/$!!; - - # set an absolute base href for asset urls under iframe embedding - if ($ENV{baseURL} =~ m!^https?://!) { + my $self = shift; - # this should only be used by MITM sites when proxying renderer assets - my $baseURL = $ENV{baseURL} =~ m!/$! ? $ENV{baseURL} : "$ENV{baseURL}/"; - $main::basehref = Mojo::URL->new($baseURL); + $self->config->{SITE_HOST} =~ s!/$!!; - # do NOT use the proxy address in our router! - $ENV{baseURL} = ''; - } elsif ($ENV{baseURL} =~ m!\S!) { + # Set an absolute base href for asset urls under iframe embedding. + if ($self->config->{baseURL} =~ m!^https?://!) { + # This should only be used by MITM sites when proxying renderer assets. + my $baseURL = $self->config->{baseURL} =~ m!/$! ? $self->config->{baseURL} : $self->config->{baseURL} . '/'; + $self->defaults->{baseHREF} = Mojo::URL->new($baseURL); - # ENV{baseURL} is used to build routes, so configure as "/extension" - $ENV{baseURL} = "/$ENV{baseURL}"; - warn "*** [CONFIG] baseURL should not end in a slash\n" - if $ENV{baseURL} =~ s!/$!!; - warn "*** [CONFIG] baseURL should begin with a slash\n" - unless $ENV{baseURL} =~ s!^//!/!; + # Do NOT use the proxy address for the router! + $self->config->{baseURL} = ''; + } elsif ($self->config->{baseURL} =~ m!\S!) { + # Ensure baseURL starts with a slash but doesn't end with a slash. + $self->config->{baseURL} = '/' . $self->config->{baseURL} unless $self->config->{baseURL} =~ m!^/!; + $self->config->{baseURL} =~ s!/$!!; - # base href must end in a slash when not hosting at the root - $main::basehref = - Mojo::URL->new($ENV{SITE_HOST})->path("$ENV{baseURL}/"); + # base href must end in a slash when not hosting at the root. + $self->defaults->{baseHREF} = Mojo::URL->new($self->config->{SITE_HOST})->path($self->config->{baseURL} . '/'); } else { # no proxy and service is hosted at the root of SITE_HOST - $main::basehref = Mojo::URL->new($ENV{SITE_HOST}); + $self->defaults->{baseHREF} = Mojo::URL->new($self->config->{SITE_HOST}); } - if ($ENV{formURL} =~ m!\S!) { - + if ($self->config->{formURL} =~ m!\S!) { # this should only be used by MITM - $main::formURL = Mojo::URL->new($ENV{formURL}); + $self->defaults->{formURL} = Mojo::URL->new($self->config->{formURL}); die '*** [CONFIG] if provided, formURL must be absolute' - unless $main::formURL->is_abs; + unless $self->defaults->{formURL}->is_abs; } else { # if using MITM proxy base href + renderer api not at SITE_HOST root # provide form url as absolute SITE_HOST/extension/render-api - $main::formURL = - Mojo::URL->new($ENV{SITE_HOST})->path("$ENV{baseURL}/render-api"); + $self->defaults->{formURL} = + Mojo::URL->new($self->config->{SITE_HOST})->path($self->config->{baseURL} . '/render-api'); } } diff --git a/lib/Renderer/Controller/IO.pm b/lib/Renderer/Controller/IO.pm index 7fbc510c4..d5688e068 100644 --- a/lib/Renderer/Controller/IO.pm +++ b/lib/Renderer/Controller/IO.pm @@ -1,6 +1,6 @@ package Renderer::Controller::IO; -use Mojo::Base -async_await; -use Mojo::Base 'Mojolicious::Controller'; +use Mojo::Base 'Mojolicious::Controller', -async_await; + use File::Spec::Functions qw(splitdir); use File::Find qw(find); use MIME::Base64 qw(decode_base64); @@ -37,7 +37,7 @@ sub raw { return unless $validatedInput; my $file_path = $validatedInput->{sourceFilePath}; - my $problem = $c->newProblem({ log => $c->log, read_path => $file_path }); + my $problem = $c->newProblem({ read_path => $file_path }); $problem->{action} = 'fetch source'; return $c->exception($problem->{_message}, $problem->{status}) unless $problem->success(); @@ -58,7 +58,6 @@ async sub writer { return unless $validatedInput; my $problem = $c->newProblem({ - log => $c->log, write_path => $validatedInput->{writeFilePath}, problem_contents => $validatedInput->{problemSource} }); @@ -362,7 +361,7 @@ async sub findNewVersion { my $avoidProblems = {}; $c->render_later; for my $seed (@avoidSeeds) { - my $problem = $c->newProblem({ log => $c->log, read_path => $filePath, random_seed => $seed }); + my $problem = $c->newProblem({ read_path => $filePath, random_seed => $seed }); my $renderedProblem = await $problem->render({}); next unless ($problem->success()); $avoidProblems->{$seed} = decode_json($renderedProblem); @@ -375,7 +374,7 @@ async sub findNewVersion { $newSeed = 1 + int rand(999999); } until (!exists($avoidProblems->{$newSeed})); - my $newProblemObj = $c->newProblem({ log => $c->log, read_path => $filePath, random_seed => $newSeed }); + my $newProblemObj = $c->newProblem({ read_path => $filePath, random_seed => $newSeed }); my $newProblemJson = await $newProblemObj->render({}); next unless ($newProblemObj->success()); $newProblem = decode_json($newProblemJson); @@ -453,7 +452,7 @@ async sub findUniqueSeeds { do { $newSeed = 1 + int rand(999999); } until (!exists($triedSeeds->{$newSeed})); - my $newProblemObj = $c->newProblem({ log => $c->log, read_path => $filePath, random_seed => $newSeed }); + my $newProblemObj = $c->newProblem({ read_path => $filePath, random_seed => $newSeed }); my $newProblemJson = await $newProblemObj->render({}); next unless ($newProblemObj->success()); $newProblem = decode_json($newProblemJson); @@ -519,7 +518,7 @@ async sub setTags { # the same holds for keywords $incomingTags->{keywords} = [ $incomingTags->{keywords} ] unless (ref($incomingTags->{keywords}) =~ /ARRAY/); - my $problem = $c->newProblem({ log => $c->log, read_path => $incomingTags->{file} }); + my $problem = $c->newProblem({ read_path => $incomingTags->{file} }); # wrap the get/update/write tags in a promise my $tags = WeBWorK::Utils::Tags->new($incomingTags->{file}); diff --git a/lib/Renderer/Controller/Render.pm b/lib/Renderer/Controller/Render.pm index 632384e3e..912ef0abf 100644 --- a/lib/Renderer/Controller/Render.pm +++ b/lib/Renderer/Controller/Render.pm @@ -15,8 +15,9 @@ sub parseRequest { // '' =~ s!^\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*$!$1!r; $originIP ||= $c->tx->remote_address || 'unknown-origin'; - if ($ENV{STRICT_JWT} && !(defined $params{problemJWT} || defined $params{sessionJWT})) { - return $c->exception('Not allowed to request problems with raw data.', 403); + if ($c->config->{STRICT_JWT} && !(defined $params{problemJWT} || defined $params{sessionJWT})) { + $c->exception('Not allowed to request problems with raw data.', 403); + return; } # protect against DOM manipulation @@ -41,8 +42,8 @@ sub parseRequest { eval { $claims = decode_jwt( token => $sessionJWT, - key => $ENV{webworkJWTsecret}, - verify_iss => $ENV{SITE_HOST}, + key => $c->config->{webworkJWTsecret}, + verify_iss => $c->config->{SITE_HOST}, ); 1; } or do { @@ -65,8 +66,8 @@ sub parseRequest { eval { $claims = decode_jwt( token => $problemJWT, - key => $ENV{problemJWTsecret}, - verify_aud => $ENV{SITE_HOST}, + key => $c->config->{problemJWTsecret}, + verify_aud => $c->config->{SITE_HOST}, ); 1; } or do { @@ -78,12 +79,12 @@ sub parseRequest { @params{ keys %$claims } = values %$claims; } elsif ($params{outputFormat} ne 'ptx') { # if no JWT is provided, create one (unless this is a pretext request) - $params{aud} = $ENV{SITE_HOST}; + $params{aud} = $c->config->{SITE_HOST}; $params{isInstructor} //= 0; $params{sessionID} ||= time; my $req_jwt = encode_jwt( payload => \%params, - key => $ENV{problemJWTsecret}, + key => $c->config->{problemJWTsecret}, alg => 'PBES2-HS512+A256KW', enc => 'A256GCM', auto_iat => 1 @@ -156,7 +157,6 @@ async sub problem { } my $problem = $c->newProblem({ - log => $c->log, read_path => $file_path, random_seed => $random_seed, problem_contents => $problem_contents @@ -232,7 +232,7 @@ async sub sendAnswerJWT { message => 'initial message' }; my $header = { - Origin => $ENV{SITE_HOST}, + Origin => $c->config->{SITE_HOST}, 'Content-Type' => 'text/plain', }; @@ -304,10 +304,10 @@ sub jweFromRequest { my $c = shift; my $inputs_ref = $c->parseRequest; return unless $inputs_ref; - $inputs_ref->{aud} = $ENV{SITE_HOST}; + $inputs_ref->{aud} = $c->config->{SITE_HOST}; my $req_jwt = encode_jwt( payload => $inputs_ref, - key => $ENV{problemJWTsecret}, + key => $c->config->{problemJWTsecret}, alg => 'PBES2-HS512+A256KW', enc => 'A256GCM', auto_iat => 1 @@ -319,10 +319,10 @@ sub jwtFromRequest { my $c = shift; my $inputs_ref = $c->parseRequest; return unless $inputs_ref; - $inputs_ref->{aud} = $ENV{SITE_HOST}; + $inputs_ref->{aud} = $c->config->{SITE_HOST}; my $req_jwt = encode_jwt( payload => $inputs_ref, - key => $ENV{problemJWTsecret}, + key => $c->config->{problemJWTsecret}, alg => 'HS256', auto_iat => 1 ); diff --git a/lib/Renderer/Model/Problem.pm b/lib/Renderer/Model/Problem.pm index 1b1b0ca58..29daddd05 100644 --- a/lib/Renderer/Model/Problem.pm +++ b/lib/Renderer/Model/Problem.pm @@ -41,21 +41,14 @@ our $codes = { }; sub new { - my $class = shift; - my $problem_ref = { + my ($class, $c, $args) = @_; + my $self = bless { + c => $c, _error => '', action => '', code_origin => '', - }; - bless $problem_ref, $class; - $problem_ref->{start} = time; - $problem_ref->_init(@_); - return $problem_ref; -} - -sub _init { - my ($self, $args) = @_; - $self->{log} = $args->{log} if $args->{log}; + start => time + }, $class; my $read_path = $args->{read_path} || ''; my $write_path = $args->{write_path} || ''; @@ -80,9 +73,13 @@ sub _init { my $path_info = $self->{code_origin}; my $seed_info = $args->{random_seed} ? "random seed #" . $args->{random_seed} : "no random seed."; - $self->{log}->info("CREATED: Problem created from $path_info with $seed_info"); + $self->c->log->info("CREATED: Problem created from $path_info with $seed_info"); + + return $self; } +sub c { my $self = shift; return $self->{c}; } + sub source { my $self = shift; if (scalar(@_) == 1) { @@ -110,18 +107,19 @@ sub seed { return $self->{random_seed}; } +my $oplRoot = Mojo::File::curfile->dirname->dirname->dirname->dirname->child('webwork-open-problem-library'); + sub path { my $self = shift; if (scalar(@_) >= 1) { my $read_path = shift; my $force = shift if @_; $read_path =~ s!\s+|\.\./!!g; # prevent backtracking and whitespace - my $opl_root = $ENV{OPL_DIRECTORY}; if ($read_path =~ m!^Library/!) { - $read_path =~ s!^Library/!$opl_root/OpenProblemLibrary/!; + $read_path =~ s!^Library/!$oplRoot/OpenProblemLibrary/!; $self->{write_allowed} = 0; } elsif ($read_path =~ m!^Contrib!) { - $read_path =~ s!^Contrib/!$opl_root/Contrib/!; + $read_path =~ s!^Contrib/!$oplRoot/Contrib/!; $self->{write_allowed} = 0; # eventually reconsider this? } else { # TODO: consider steps in pipeline towards OPL @@ -144,11 +142,10 @@ sub target { if (scalar(@_) == 1) { my $write_path = shift; $write_path =~ s!\s+|\.\./!!g; # prevent backtracking and whitespace - my $opl_root = $ENV{OPL_DIRECTORY}; if ($write_path =~ m!^Library/!) { - $write_path =~ s!^Library/!$opl_root/OpenProblemLibrary/!; + $write_path =~ s!^Library/!$oplRoot/OpenProblemLibrary/!; } elsif ($write_path =~ m!^Contrib!) { - $write_path =~ s!^Contrib/!$opl_root/Contrib/!; + $write_path =~ s!^Contrib/!$oplRoot/Contrib/!; } # TODO: include permission check to write to this path... @@ -223,7 +220,7 @@ sub render { sub success { my $self = shift; - $self->{log}->error($self->{exception}) if ($self->{log} && $self->{exception}); + $self->c->log->error($self->{exception}) if $self->{exception}; my $report = ($self->{_error} =~ /\S/) ? $self->{_error} : 'NO ERRORS'; return 1 unless $self->{_error} =~ /\S/; my ($code, $mesg) = split(/ /, $self->{_error}, 2); @@ -240,9 +237,9 @@ sub DESTROY { $logmsg .= $self->{action} . ' from '; $logmsg .= $self->{code_origin}; if ($self->{_error} && $self->{_error} =~ /\S/) { - $self->{log}->error("$logmsg failed with error: " . $self->{_error}); + $self->c->log->error("$logmsg failed with error: " . $self->{_error}); } else { - $self->{log}->info("$logmsg succeeded."); + $self->c->log->info("$logmsg succeeded."); } } diff --git a/lib/Renderer/Plugin/Assets.pm b/lib/Renderer/Plugin/Assets.pm new file mode 100644 index 000000000..05d3194dc --- /dev/null +++ b/lib/Renderer/Plugin/Assets.pm @@ -0,0 +1,120 @@ +package Renderer::Plugin::Assets; +use Mojo::Base 'Mojolicious::Plugin', -signatures; + +use Mojo::File qw(path); +use Mojo::JSON qw(decode_json); + +my $staticRendererAssets; +my $staticPGAssets; +my $thirdPartyRendererDependencies; +my $thirdPartyPGDependencies; + +sub register ($plugin, $app, $conf) { + $app->helper( + getAssetURL => sub ($c, $file, $language = $app->config->{language}) { + my $renderRoot = $app->home; + + # Load the static files list generated by `npm install` the first time this method is called. + unless ($staticRendererAssets) { + my $staticAssetsList = $renderRoot->child('public', 'static-assets.json'); + $staticRendererAssets = readJSON($staticAssetsList); + unless ($staticRendererAssets) { + warn "ERROR: '$staticAssetsList' not found or not readable!\n" + . "You may need to run 'npm install' from '$renderRoot/public'."; + $staticRendererAssets = {}; + } + } + + unless ($staticPGAssets) { + my $staticAssetsList = path($ENV{PG_ROOT})->child('htdocs', 'static-assets.json'); + $staticPGAssets = readJSON($staticAssetsList); + unless ($staticPGAssets) { + warn "ERROR: '$staticAssetsList' not found or not readable!\n" + . "You may need to run 'npm install' from '$ENV{PG_ROOT}/htdocs'."; + $staticPGAssets = {}; + } + } + + unless ($thirdPartyRendererDependencies) { + my $packageJSON = $renderRoot->child('public', 'package.json'); + my $data = readJSON($packageJSON); + warn "ERROR: '$packageJSON' not found or not readable!\n" + unless $data && defined $data->{dependencies}; + $thirdPartyRendererDependencies = $data->{dependencies} // {}; + } + + unless ($thirdPartyPGDependencies) { + my $packageJSON = path($ENV{PG_ROOT})->child('htdocs', 'package.json'); + my $data = readJSON($packageJSON); + warn "ERROR: '$packageJSON' not found or not readable!\n" + unless $data && defined $data->{dependencies}; + $thirdPartyPGDependencies = $data->{dependencies} // {}; + } + + # Check to see if this is a third party asset file in node_modules (either in renderer/public or pg/htdocs). + # If so, then either serve it from a CDN if requested, or serve it directly with the library version + # appended as a URL parameter. + if ($file =~ /^node_modules/) { + my $rendererFile = getThirdPartyAssetURL($c, $file, $thirdPartyRendererDependencies, 'publicFile'); + return $rendererFile if $rendererFile; + + my $pgFile = getThirdPartyAssetURL($c, $file, $thirdPartyPGDependencies, 'pgFile',); + return $pgFile if $pgFile; + } + + # If a right-to-left language is enabled (Hebrew or Arabic) and this is a css file that is not a third party + # asset, then determine the rtl varaint file name. This will be looked for first in the asset lists. + my $rtlfile = + ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/) + ? $file =~ s/\.css$/.rtl.css/r + : undef; + + # First check to see if this is a file in the renderer public location with a rtl variant. + return $c->url_for('publicFile', static => $staticRendererAssets->{$rtlfile})->to_string + if defined $rtlfile && defined $staticRendererAssets->{$rtlfile}; + + # Next check to see if this is a file in the renderer public location. + return $c->url_for('publicFile', static => $staticRendererAssets->{$file})->to_string + if defined $staticRendererAssets->{$file}; + + # Now check to see if this is a file in the pg htdocs location with a rtl variant. + return $c->url_for('pgFile', static => $staticPGAssets->{$rtlfile})->to_string + if defined $rtlfile && defined $staticPGAssets->{$rtlfile}; + + # Next check to see if this is a file in the pg htdocs location. + return $c->url_for('pgFile', static => $staticPGAssets->{$file})->to_string + if defined $staticPGAssets->{$file}; + + # If the file was not found in the lists, then just use the given file and assume its path is relative to the + # render app public folder. + return $c->url_for('publicFile', static => $file)->to_string; + } + ); + return; +} + +sub readJSON ($filePath) { + return unless -r $filePath; + my $data = decode_json($filePath->slurp('UTF-8')); + die "FATAL: Unable to open '$filePath'!" if $@; + return $data; +} + +sub getThirdPartyAssetURL ($c, $file, $dependencies, $routeName) { + for (keys %$dependencies) { + if ($file =~ /^node_modules\/$_\/(.*)$/) { + if ($c->config->{thirdPartyAssetsUseCDN}) { + return + "https://cdn.jsdelivr.net/npm/$_\@" + . substr($dependencies->{$_}, 1) . '/' + . ($1 =~ s/(?:\.min)?\.(js|css)$/.min.$1/gr); + } else { + return $c->url_for($routeName, static => $file)->query(version => $dependencies->{$_} =~ s/#/@/gr) + ->to_string; + } + } + } + return; +} + +1; diff --git a/lib/WeBWorK/FormatRenderedProblem.pm b/lib/WeBWorK/FormatRenderedProblem.pm index bc7fcd0f0..f8d1eb447 100644 --- a/lib/WeBWorK/FormatRenderedProblem.pm +++ b/lib/WeBWorK/FormatRenderedProblem.pm @@ -16,7 +16,6 @@ use Mojo::DOM; use Mojo::URL; use WeBWorK::Localize; -use WeBWorK::Utils qw(getAssetURL); use WeBWorK::Utils::LanguageAndDirection; sub formatRenderedProblem { @@ -35,28 +34,23 @@ sub formatRenderedProblem { } # TODO: add configuration to disable these overrides - my $SITE_URL = $inputs_ref->{baseURL} ? Mojo::URL->new($inputs_ref->{baseURL}) : $main::basehref; - my $FORM_ACTION_URL = $inputs_ref->{formURL} ? Mojo::URL->new($inputs_ref->{formURL}) : $main::formURL; + my $SITE_URL = $inputs_ref->{baseURL} ? Mojo::URL->new($inputs_ref->{baseURL}) : $c->stash->{baseHREF}; + my $FORM_ACTION_URL = $inputs_ref->{formURL} ? Mojo::URL->new($inputs_ref->{formURL}) : $c->stash->{formURL}; my $displayMode = $inputs_ref->{displayMode} // 'MathJax'; # HTML document language setting - my $formLanguage = $inputs_ref->{language} // 'en'; + my $formLanguage = $inputs_ref->{language} // $c->config->{language}; # Third party CSS - my @third_party_css = map { getAssetURL($formLanguage, $_->[0]) } ( - [ 'css/bootstrap.css', ], - [ 'node_modules/jquery-ui-dist/jquery-ui.min.css', ], + my @third_party_css = map { $c->getAssetURL($_->[0], $formLanguage) } ( + ['css/bootstrap.css'], + ['node_modules/jquery-ui-dist/jquery-ui.min.css'], ['node_modules/@fortawesome/fontawesome-free/css/all.min.css'], ); - # Add CSS files requested by problems via ADD_CSS_FILE() in the PG file - # or via a setting of $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} - # which can be set in course.conf (the value should be an anonomous array). + # Add CSS files requested by problems via ADD_CSS_FILE() in the PG file. my @cssFiles; - # if (ref($ce->{pg}{specialPGEnvironmentVars}{extra_css_files}) eq 'ARRAY') { - # push(@cssFiles, { file => $_, external => 0 }) for @{ $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} }; - # } if (ref($rh_result->{flags}{extra_css_files}) eq 'ARRAY') { push @cssFiles, @{ $rh_result->{flags}{extra_css_files} }; } @@ -68,22 +62,22 @@ sub formatRenderedProblem { if ($_->{external}) { push(@extra_css_files, $_); } else { - push(@extra_css_files, { file => getAssetURL($formLanguage, $_->{file}), external => 0 }); + push(@extra_css_files, { file => $c->getAssetURL($_->{file}, $formLanguage), external => 0 }); } } # Third party JavaScript # The second element is a hash containing the necessary attributes for the script tag. - my @third_party_js = map { [ getAssetURL($formLanguage, $_->[0]), $_->[1] ] } ( + my @third_party_js = map { [ $c->getAssetURL($_->[0], $formLanguage), $_->[1] ] } ( [ 'node_modules/jquery/dist/jquery.min.js', {} ], [ 'node_modules/jquery-ui-dist/jquery-ui.min.js', {} ], [ 'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js', {} ], - [ "js/apps/MathJaxConfig/mathjax-config.js", { defer => undef } ], + [ "js/MathJaxConfig/mathjax-config.js", { defer => undef } ], [ 'node_modules/mathjax/es5/tex-svg.js', { defer => undef, id => 'MathJax-script' } ], [ 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', { defer => undef } ], - [ "js/apps/Problem/problem.js", { defer => undef } ], - [ "js/apps/Problem/submithelper.js", { defer => undef } ], - [ "js/apps/CSSMessage/css-message.js", { defer => undef } ], + [ "js/Problem/problem.js", { defer => undef } ], + [ "js/Problem/submithelper.js", { defer => undef } ], + [ "js/CSSMessage/css-message.js", { defer => undef } ], ); # Get the requested format. (outputFormat or outputformat) @@ -103,7 +97,7 @@ sub formatRenderedProblem { push( @extra_js_files, { - file => getAssetURL($formLanguage, $_->{file}), + file => $c->getAssetURL($_->{file}, $formLanguage), external => 0, attributes => $_->{attributes} } @@ -209,7 +203,7 @@ sub formatRenderedProblem { template => $formatName eq 'ptx' ? 'RPCRenderFormats/ptx' : 'RPCRenderFormats/default', $formatName eq 'json' ? (format => 'json') : (), formatName => $formatName, - lh => WeBWorK::Localize::getLangHandle($inputs_ref->{language} // 'en'), + lh => WeBWorK::Localize::getLangHandle($formLanguage), rh_result => $rh_result, SITE_URL => $SITE_URL, FORM_ACTION_URL => $FORM_ACTION_URL, diff --git a/lib/WeBWorK/PreTeXt.pm b/lib/WeBWorK/PreTeXt.pm index 5a557bcbe..a6d9abd8c 100644 --- a/lib/WeBWorK/PreTeXt.pm +++ b/lib/WeBWorK/PreTeXt.pm @@ -3,11 +3,13 @@ package WeBWorK::PreTeXt; use strict; use warnings; +use Mojo::File; use Mojo::DOM; use Mojo::IOLoop; use Data::Structure::Util qw(unbless); -use lib "$ENV{PG_ROOT}/lib"; +use lib Mojo::File::curfile->dirname->dirname->child('PG', 'lib'); + use WeBWorK::PG; sub render_ptx { @@ -43,4 +45,5 @@ sub render_ptx { return "error: $err"; }); } + 1; diff --git a/lib/WeBWorK/RenderProblem.pm b/lib/WeBWorK/RenderProblem.pm index 78fc8992c..10c4d2eb5 100644 --- a/lib/WeBWorK/RenderProblem.pm +++ b/lib/WeBWorK/RenderProblem.pm @@ -6,32 +6,18 @@ use warnings; # for logs use Time::HiRes qw/time/; use Proc::ProcessTable; -use Date::Format; +use Mojo::File; use Mojo::JSON qw( encode_json ); use Crypt::JWT qw( encode_jwt ); use Digest::MD5 qw( md5_hex ); -use lib "$ENV{PG_ROOT}/lib"; +use lib Mojo::File::curfile->dirname->dirname->child('PG', 'lib'); use WeBWorK::PG; use WeBWorK::Utils::Tags; -################################################## -# create log files :: expendable -################################################## - -my $path_to_log_file = "$ENV{RENDER_ROOT}/logs/resource_usage.log"; - -eval { # attempt to create log file - local (*FH); - open(FH, '>>:encoding(UTF-8)', $path_to_log_file) - or die "Can't open file $path_to_log_file for writing"; - close(FH); -}; - -die "You must first create an output file at $path_to_log_file with permissions 777 " - unless -w $path_to_log_file; +my $renderRoot = Mojo::File::curfile->dirname->dirname->dirname; ################################################## # define universal TO_JSON for JSON::XS unbless @@ -78,7 +64,7 @@ sub process_pg_file { my $log_file_path = $inputs_ref->{sourceFilePath} || 'source provided without path'; my $memory_use_end = get_current_process_memory(); my $memory_use = $memory_use_end - $memory_use_start; - writeRenderLogEntry( + $problem->c->resourceUsageLog( sprintf("(duration: %.3f sec) ", $pg_duration) . sprintf("{memory: %6d bytes} ", $memory_use) . "file: $log_file_path" @@ -140,7 +126,7 @@ sub process_problem { $error_string = ''; # can include @args as third input below - $return_object = standaloneRenderer(\$source, $inputs_ref); + $return_object = renderPG($problem->c, \$source, $inputs_ref); # stash assets list in $return_object $return_object->{pgResources} = \@pgResources; @@ -151,7 +137,7 @@ sub process_problem { # if this is a preview, leave session unmodified, and no answerJWT $return_object->{sessionJWT} = $inputs_ref->{sessionJWT}; } elsif ($inputs_ref->{problemJWT}) { - my ($sessionJWT, $answerJWT) = generateJWTs($return_object, $inputs_ref); + my ($sessionJWT, $answerJWT) = generateJWTs($problem->c, $return_object, $inputs_ref); $return_object->{sessionJWT} = $sessionJWT; $return_object->{answerJWT} = $answerJWT; } @@ -182,7 +168,8 @@ sub process_problem { # standalonePGproblemRenderer ########################################### -sub standaloneRenderer { +sub renderPG { + my $c = shift; my $problemFile = shift // ''; my $inputs_ref = shift // {}; my %args = @_; @@ -221,9 +208,9 @@ sub standaloneRenderer { psvn => $inputs_ref->{psvn}, problemUUID => $inputs_ref->{problemUUID}, language => $inputs_ref->{language} // 'en', - templateDirectory => "$ENV{RENDER_ROOT}/", - htmlURL => 'pg_files/', - tempURL => 'pg_files/tmp/', + templateDirectory => "$renderRoot/", + htmlURL => $c->url_for('pgFile', static => '')->to_string, + tempURL => $c->url_for('pgTempFile', static => '')->to_string, debuggingOptions => { show_resource_info => $inputs_ref->{show_resource_info}, view_problem_debugging_info => $inputs_ref->{view_problem_debugging_info} @@ -287,10 +274,12 @@ sub get_current_process_memory { # expects a pg/result_object and a ref to submitted formdata # generates a sessionJWT and an answerJWT sub generateJWTs { - my $pg = shift; - my $inputs_ref = shift; + my $c = shift; + my $pg = shift; + my $inputs_ref = shift; + my $sessionHash = { - iss => $ENV{SITE_HOST}, + iss => $c->config->{SITE_HOST}, answersSubmitted => 1, sessionID => $inputs_ref->{sessionID}, problemUUID => $inputs_ref->{problemUUID}, @@ -301,13 +290,6 @@ sub generateJWTs { answers => unbless($pg->{answers}), }; - # proposed restructuring of the answerJWT -- prepare with LibreTexts - # my %studentKeys = qw(student_value value student_formula formula student_ans answer original_student_ans original); - # my %previewKeys = qw(preview_text_string text preview_latex_string latex); - # my %correctKeys = qw(correct_value value correct_formula formula correct_ans ans); - # my %messageKeys = qw(ans_message answer error_message error); - # my @resultKeys = qw(score weight); - # once the correct answers are shown, this setting is permanent if ($inputs_ref->{showCorrectAnswers} && !$inputs_ref->{isInstructor}) { $sessionHash->{showCorrectAnswers} = 1; @@ -315,15 +297,8 @@ sub generateJWTs { } # store the current answer/response state for each entry - foreach my $ans (@{ $pg->{flags}{KEPT_EXTRA_ANSWERS} }) { + for my $ans (@{ $pg->{flags}{KEPT_EXTRA_ANSWERS} }) { $sessionHash->{$ans} = $inputs_ref->{$ans}; - -# More restructuring -- confirm with LibreTexts -# $scoreHash->{$ans}{student} = { map {exists $answers{$ans}{$_} ? ($studentKeys{$_} => $answers{$ans}{$_}) : ()} keys %studentKeys }; -# $scoreHash->{$ans}{preview} = { map {exists $answers{$ans}{$_} ? ($previewKeys{$_} => $answers{$ans}{$_}) : ()} keys %previewKeys }; -# $scoreHash->{$ans}{correct} = { map {exists $answers{$ans}{$_} ? ($correctKeys{$_} => $answers{$ans}{$_}) : ()} keys %correctKeys }; -# $scoreHash->{$ans}{message} = { map {exists $answers{$ans}{$_} ? ($messageKeys{$_} => $answers{$ans}{$_}) : ()} keys %messageKeys }; -# $scoreHash->{$ans}{result} = { map {exists $answers{$ans}{$_} ? ($_ => $answers{$ans}{$_}) : ()} @resultKeys }; } # update the number of correct/incorrect submissions if answers were 'submitted' @@ -338,19 +313,21 @@ sub generateJWTs { : ($inputs_ref->{numIncorrect} // 0); # create the session JWT - my $sessionJWT = encode_jwt(payload => $sessionHash, auto_iat => 1, alg => 'HS256', key => $ENV{webworkJWTsecret}); + my $sessionJWT = + encode_jwt(payload => $sessionHash, auto_iat => 1, alg => 'HS256', key => $c->config->{webworkJWTsecret}); # form answerJWT my $responseHash = { - iss => $ENV{SITE_HOST}, + iss => $c->config->{SITE_HOST}, aud => $inputs_ref->{JWTanswerURL}, score => $scoreHash, sessionJWT => $sessionJWT, - platform => 'standaloneRenderer' + platform => 'renderer' }; # Can instead use alg => 'PBES2-HS512+A256KW', enc => 'A256GCM' for JWE - my $answerJWT = encode_jwt(payload => $responseHash, alg => 'HS256', key => $ENV{problemJWTsecret}, auto_iat => 1); + my $answerJWT = + encode_jwt(payload => $responseHash, alg => 'HS256', key => $c->config->{problemJWTsecret}, auto_iat => 1); return ($sessionJWT, $answerJWT); } @@ -371,7 +348,7 @@ sub pretty_print_rh { if (ref($rh) =~ /HASH/) { $out .= "{\n"; $indent++; - foreach my $key (sort keys %{$rh}) { + for my $key (sort keys %{$rh}) { $out .= " " x $indent . "$key => " . pretty_print_rh($rh->{$key}, $indent) . "\n"; } $indent--; @@ -395,16 +372,4 @@ sub pretty_print_rh { return $out . " "; } -sub writeRenderLogEntry($) { - my $message = shift; - - local *LOG; - if (open LOG, ">>", $path_to_log_file) { - print LOG "[", time2str("%a %b %d %H:%M:%S %Y", time), "] $message\n"; - close LOG; - } else { - warn "failed to open $path_to_log_file for writing: $!"; - } -} - 1; diff --git a/lib/WeBWorK/Utils.pm b/lib/WeBWorK/Utils.pm index 3db77b82d..13b0c3b4c 100644 --- a/lib/WeBWorK/Utils.pm +++ b/lib/WeBWorK/Utils.pm @@ -4,12 +4,7 @@ use base qw(Exporter); use strict; use warnings; -use Mojo::JSON qw(decode_json); - -our @EXPORT_OK = qw( - wwRound - getAssetURL -); +our @EXPORT_OK = qw(wwRound); # usage wwRound($places,$float) # return $float rounded up to number of decimal places given by $places @@ -20,115 +15,4 @@ sub wwRound(@) { return int($float * $factor + 0.5) / $factor; } -my $staticWWAssets; -my $staticPGAssets; -my $thirdPartyWWDependencies; -my $thirdPartyPGDependencies; - -sub readJSON { - my $fileName = shift; - - return unless -r $fileName; - - open(my $fh, "<:encoding(UTF-8)", $fileName) or die "FATAL: Unable to open '$fileName'!"; - local $/; - my $data = <$fh>; - close $fh; - - return decode_json($data); -} - -sub getThirdPartyAssetURL { - my ($file, $dependencies, $baseURL, $useCDN) = @_; - - for (keys %$dependencies) { - if ($file =~ /^node_modules\/$_\/(.*)$/) { - if ($useCDN && $1 !~ /mathquill/) { - return - "https://cdn.jsdelivr.net/npm/$_\@" - . substr($dependencies->{$_}, 1) . '/' - . ($1 =~ s/(?:\.min)?\.(js|css)$/.min.$1/gr); - } else { - return Mojo::URL->new("${baseURL}$file")->query(version => $dependencies->{$_} =~ s/#/@/gr)->to_string; - } - } - } - return; -} - -# Get the url for static assets. -sub getAssetURL { - my ($language, $file) = @_; - - # Load the static files list generated by `npm install` the first time this method is called. - unless ($staticWWAssets) { - my $staticAssetsList = "$ENV{RENDER_ROOT}/public/static-assets.json"; - $staticWWAssets = readJSON($staticAssetsList); - unless ($staticWWAssets) { - warn "ERROR: '$staticAssetsList' not found or not readable!\n" - . "You may need to run 'npm install' from '$ENV{RENDER_ROOT}/public'."; - $staticWWAssets = {}; - } - } - - unless ($staticPGAssets) { - my $staticAssetsList = "$ENV{PG_ROOT}/htdocs/static-assets.json"; - $staticPGAssets = readJSON($staticAssetsList); - unless ($staticPGAssets) { - warn "ERROR: '$staticAssetsList' not found or not readable!\n" - . "You may need to run 'npm install' from '$ENV{PG_ROOT}/htdocs'."; - $staticPGAssets = {}; - } - } - - unless ($thirdPartyWWDependencies) { - my $packageJSON = "$ENV{RENDER_ROOT}/public/package.json"; - my $data = readJSON($packageJSON); - warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; - $thirdPartyWWDependencies = $data->{dependencies} // {}; - } - - unless ($thirdPartyPGDependencies) { - my $packageJSON = "$ENV{PG_ROOT}/htdocs/package.json"; - my $data = readJSON($packageJSON); - warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; - $thirdPartyPGDependencies = $data->{dependencies} // {}; - } - - # Check to see if this is a third party asset file in node_modules (either in webwork2/htdocs or pg/htdocs). - # If so, then either serve it from a CDN if requested, or serve it directly with the library version - # appended as a URL parameter. - if ($file =~ /^node_modules/) { - my $wwFile = getThirdPartyAssetURL($file, $thirdPartyWWDependencies, '', 0); - return $wwFile if $wwFile; - - my $pgFile = getThirdPartyAssetURL($file, $thirdPartyPGDependencies, 'pg_files/', 1); - return $pgFile if $pgFile; - } - - # If a right-to-left language is enabled (Hebrew or Arabic) and this is a css file that is not a third party asset, - # then determine the rtl varaint file name. This will be looked for first in the asset lists. - my $rtlfile = - ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/) - ? $file =~ s/\.css$/.rtl.css/r - : undef; - - # First check to see if this is a file in the webwork htdocs location with a rtl variant. - return "$staticWWAssets->{$rtlfile}" - if defined $rtlfile && defined $staticWWAssets->{$rtlfile}; - - # Next check to see if this is a file in the webwork htdocs location. - return "$staticWWAssets->{$file}" if defined $staticWWAssets->{$file}; - - # Now check to see if this is a file in the pg htdocs location with a rtl variant. - return "pg_files/$staticPGAssets->{$rtlfile}" if defined $rtlfile && defined $staticPGAssets->{$rtlfile}; - - # Next check to see if this is a file in the pg htdocs location. - return "pg_files/$staticPGAssets->{$file}" if defined $staticPGAssets->{$file}; - - # If the file was not found in the lists, then just use the given file and assume its path is relative to the - # render app public folder. - return "$file"; -} - 1; diff --git a/public/generate-assets.js b/public/generate-assets.js index 4d68de265..48cf572ad 100755 --- a/public/generate-assets.js +++ b/public/generate-assets.js @@ -196,7 +196,7 @@ const processFile = async (file, _details) => { if (ready) fs.writeFileSync(assetFile, JSON.stringify(assets)); }; -const jsDir = path.resolve(__dirname, 'js/apps'); +const jsDir = path.resolve(__dirname, 'js'); const cssDir = path.resolve(__dirname, 'css'); // Remove generated files from previous builds. @@ -208,7 +208,7 @@ if (argv.clean) process.exit(); // Set up the watcher. if (argv.watchFiles) console.log('\x1b[32mEstablishing watches and performing initial build.\x1b[0m'); chokidar - .watch(['js/apps', 'css'], { + .watch(['js', 'css'], { ignored: /layouts|\.min\.(js|css)$/, cwd: __dirname, // Make sure all paths are given relative to the htdocs directory. usePolling: true, // Needed to get changes to symlinks. diff --git a/public/js/apps/CSSMessage/css-message.js b/public/js/CSSMessage/css-message.js similarity index 100% rename from public/js/apps/CSSMessage/css-message.js rename to public/js/CSSMessage/css-message.js diff --git a/public/js/apps/MathJaxConfig/mathjax-config.js b/public/js/MathJaxConfig/mathjax-config.js similarity index 100% rename from public/js/apps/MathJaxConfig/mathjax-config.js rename to public/js/MathJaxConfig/mathjax-config.js diff --git a/public/js/apps/Problem/problem.js b/public/js/Problem/problem.js similarity index 97% rename from public/js/apps/Problem/problem.js rename to public/js/Problem/problem.js index c204f37d4..4ce896d64 100644 --- a/public/js/apps/Problem/problem.js +++ b/public/js/Problem/problem.js @@ -27,7 +27,7 @@ // set up listeners on knowl hints and solutions document.querySelectorAll('.knowl[data-type="hint"]').forEach((hint) => { - hint.addEventListener('click', (event) => { + hint.addEventListener('click', () => { window.parent.postMessage( JSON.stringify({ type: 'webwork.interaction.hint', @@ -41,7 +41,7 @@ }); document.querySelectorAll('.knowl[data-type="solution"]').forEach((solution) => { - solution.addEventListener('click', (event) => { + solution.addEventListener('click', () => { window.parent.postMessage( JSON.stringify({ type: 'webwork.interaction.solution', @@ -166,7 +166,7 @@ // we also need to trigger the submit when the user clicks the button // or when they hit enter in the input field const creditButton = document.getElementById('creditModalSubmitBtn'); - creditButton.addEventListener('click', (event) => { + creditButton.addEventListener('click', () => { creditForm.dispatchEvent(new Event('submit')); }); const creditInput = document.getElementById('creditModalEmail'); diff --git a/public/js/apps/Problem/submithelper.js b/public/js/Problem/submithelper.js similarity index 100% rename from public/js/apps/Problem/submithelper.js rename to public/js/Problem/submithelper.js diff --git a/renderer.conf.dist b/renderer.conf.dist index 084609d5a..c568b1d94 100644 --- a/renderer.conf.dist +++ b/renderer.conf.dist @@ -9,6 +9,9 @@ STRICT_JWT => 0, FULL_APP_INSECURE => 0, INTERACTION_LOG => 0, + thirdPartyAssetsUseCDN => 0, + language => 'en', + hypnotoad => { listen => ['http://*:3000'], accepts => 400, diff --git a/templates/RPCRenderFormats/default.html.ep b/templates/RPCRenderFormats/default.html.ep index 81e24bf57..5b70c3441 100644 --- a/templates/RPCRenderFormats/default.html.ep +++ b/templates/RPCRenderFormats/default.html.ep @@ -1,4 +1,4 @@ -% use WeBWorK::Utils qw(getAssetURL wwRound); +% use WeBWorK::Utils qw(wwRound); % > diff --git a/templates/columns/editorIframe.html.ep b/templates/columns/editorIframe.html.ep index e13c9bd58..b7d40ce24 100644 --- a/templates/columns/editorIframe.html.ep +++ b/templates/columns/editorIframe.html.ep @@ -1,4 +1,4 @@ -%= javascript 'node_modules/iframe-resizer/js/iframeResizer.min.js' +%= javascript getAssetURL('node_modules/iframe-resizer/js/iframeResizer.min.js')