App-Netdisco

 view release on metacpan or  search on metacpan

lib/App/Netdisco/Util/Web.pm  view on Meta::CPAN

package App::Netdisco::Util::Web;

use strict;
use warnings;

use Dancer ':syntax';
use Dancer::Plugin::DBIC 'schema';

use Time::Piece;
use Time::Seconds;
use HTML::Entities ();
use URI ();
use URI::QueryParam ();

use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/
  sort_port sort_modules
  interval_to_daterange
  sql_match
  request_is_device
  request_is_api
  request_is_api_report
  request_is_api_search
  device_display_name
  page_title
  pane_chrome
  pane_history_header
  escape_for_script_context
  escape_results_token
/;
our %EXPORT_TAGS = (all => \@EXPORT_OK);

=head1 NAME

App::Netdisco::Util::Web

=head1 DESCRIPTION

A set of helper subroutines to support parts of the Netdisco application.

There are no default exports, however the C<:all> tag will export all
subroutines.

=head1 EXPORT_OK

=head2 request_is_device

Client has requested device content under C<.../device> or C<.../device/ports>.

=cut

sub request_is_device {
  return (
    index(request->path, uri_for('/device')->path) == 0
      or
    index(request->path, uri_for('/ajax/content/device/details')->path) == 0
      or
    index(request->path, uri_for('/ajax/content/device/ports')->path) == 0
  );
}

=head2 request_is_api

Client has requested JSON format data and an endpoint under C</api>.

=cut

sub request_is_api {
  # /api/ paths are always API endpoints regardless of Accept header
  return 1 if index(request->path, uri_for('/api/')->path) == 0;
  # for other paths, require Accept: json and a return_url pointing to /api/
  return ((request->accept and request->accept =~ m/(?:json|javascript)/)
    and param('return_url')
    and index(param('return_url'), uri_for('/api/')->path) == 0);
}

=head2 request_is_api_report

Same as C<request_is_api> but also requires path to start "C</api/v1/report/...>".

=cut

sub request_is_api_report {
  return (request_is_api and (
    index(request->path, uri_for('/api/v1/report/')->path) == 0
      or
    (param('return_url')
    and index(param('return_url'), uri_for('/api/v1/report/')->path) == 0)
  ));

lib/App/Netdisco/Util/Web.pm  view on Meta::CPAN

  # between the tab asked for and the tab of the page htmx is leaving
  my $leaving_tab = $leaving ? $leaving->query_param('tab') : undef;

  return ('HX-Push-Url' => $url)
    if defined $leaving_tab and $leaving_tab ne $tab;

  return ('HX-Replace-Url' => $url);
}

# htmx sends the address bar contents with every request it makes
sub _htmx_current_url {
  my $current = request->env->{'HTTP_HX_CURRENT_URL'} or return undef;
  return URI->new($current);
}

=head2 pane_chrome( $page, $tab )

The chrome around the C<$tab> pane of C<$page> that changes with the tab: the
CSV download link, and on the device page the sidebar reset link. Returned as
C<hx-swap-oob> markup for the same response that carries the pane, so that one
answer paints the pane and everything around it.

Returns the empty string where the page shell carries neither, which is also
what an unregistered tag gets. That is not tidiness: htmx drops an out-of-band
element naming an id the page does not have, and says nothing at all about it,
so C<netdisco.js> reports the shortfall as a script error.

The sidebar itself is deliberately absent. Whether a tab has one is declared by
the sidebar templates, both by which of them exist on an include path that
site-local plugins extend and by two of them reporting that they carry only
hidden fields, so no route can answer it without duplicating template text.

=cut

sub pane_chrome {
  my ($page, $tab) = @_;
  my $config = _tab_config($page, $tab) or return '';

  return join '', _csv_download_link($page, $tab, $config),
                  _sidebar_reset_link($page, $tab);
}

# The device and search shells render the anchor for every tab and show or hide
# it per tab; the report and admin shells render it only where the tab offers a
# download, and give the job queue and the port log a different set of controls
# in the same corner.
sub _csv_download_link {
  my ($page, $tab, $config) = @_;

  if ($page eq 'report' or $page eq 'admin') {
    return '' unless $config->{'provides_csv'};
    return '' if $tab eq 'portlog' or $tab eq 'jobqueue';
  }

  my $query = request->env->{'QUERY_STRING'} || '';
  my $href = uri_for("/ajax/content/$page/$tab")->path
    . (length $query ? ('?'. $query) : '');

  return _oob_anchor('nd_csv-download', $href,
    ($config->{'provides_csv'} ? '' : ' hidden'),
    ' download="'. _escape_attr("netdisco-$page-$tab.csv") .'"',
    '<i id="nd_csv-download-icon" class="text-info far fa-file-lines fa-lg"'
    .' rel="tooltip" data-bs-placement="left" data-bs-title="Download as CSV"></i>');
}

# the fields each device tab offers, in the order the sidebar renders them, so
# that the reset address reads the way it did when the browser built it
my %RESET_FIELDS = (
  ports  => [qw/q f partial invert/],
  netmap => [qw/q/],
);

# only the device page has a reset anchor, and only for the two tabs whose
# sidebar has anything to reset. Nothing is emitted for the others, which is
# what the shell does today: the anchor keeps the address it last had, behind a
# sidebar those tabs hide anyway.
sub _sidebar_reset_link {
  my ($page, $tab) = @_;
  return '' unless $page eq 'device';

  my $fields = $RESET_FIELDS{ $tab } or return '';

  my $uri = URI->new( uri_for('/device')->path );
  $uri->query_form(tab => $tab, reset => 'on', firstsearch => 'on',
    map  {; ($_ => scalar param($_)) }
    grep {; defined scalar param($_) } @$fields);

  return _oob_anchor('nd_sidebar-reset-link', $uri->as_string, '', '',
    '<i class="nd_sidebar-reset fas fa-arrow-rotate-left"'
    .' rel="tooltip" data-bs-placement="left" data-bs-title="Reset to Defaults"'
    .' data-bs-container="body"></i>');
}

sub _oob_anchor {
  my ($id, $href, $hidden, $extra, $content) = @_;

  return '<a id="'. $id .'" hx-swap-oob="true"'. $hidden
    .' href="'. _escape_attr($href) .'"'. $extra .'>'. $content .'</a>';
}

sub _escape_attr {
  return HTML::Entities::encode_entities(shift, q{<>&"'});
}

=head2 escape_for_script_context( $json )

Makes a JSON string safe to embed as a JavaScript literal inside an HTML
C<< <script> >> element, which is how the report and search templates ship
their result sets. Returns anything that is not a defined plain scalar
unchanged, so a resultset or an arrayref passed to a CSV or API template is
left alone.

=cut

sub escape_for_script_context {
  my $json = shift;
  return $json if (not defined $json) or ref $json;

  # Inside a script element the HTML parser ends the element at "</" and
  # changes state at "<!--" and "<script", none of which it stops doing just
  # because the sequence sits inside a JavaScript string. Escaping every "<"
  # covers all three. Both JSON.parse and the JavaScript parser read the
  # escape back as "<", so the data the page receives is unchanged.
  $json =~ s/</\\u003C/g;

  # Not an HTML concern. This JSON is emitted as JavaScript source rather than
  # parsed from a string, and these two characters are line terminators there.
  $json =~ s/\x{2028}/\\u2028/g;
  $json =~ s/\x{2029}/\\u2029/g;

  return $json;
}

=head2 escape_results_token( $tokens )

Applies C<escape_for_script_context> to the C<results> token of a template
token hash, in place, and returns the hash.

Only when the key is already there. The API serializer chooses between
emitting C<results> alone and walking the whole token hash on C<exists
$tokens-E<gt>{results}>, and a handler that renders without that token, as
node search does, needs the second. Assigning unconditionally would
autovivify the key and silently move such a handler onto the first branch.

=cut

sub escape_results_token {
  my $tokens = shift;
  return $tokens unless (ref {} eq ref $tokens) and exists $tokens->{results};

  $tokens->{results} = escape_for_script_context( $tokens->{results} );
  return $tokens;
}

1;



( run in 1.629 second using v1.01-cache-2.11-cpan-54e63673c56 )