Database-BI

 view release on metacpan or  search on metacpan

lib/Database/BI/Controller/Dashboard.pm  view on Meta::CPAN

Readonly my $MAX_UPLOAD_MIB      => 50;
Readonly my $MAX_UPLOAD_BYTES    => $MAX_UPLOAD_MIB * 1_048_576;
Readonly my $DEFAULT_JOIN_MAX_ROWS => 10_000;
Readonly my $MAX_PREFIX_LEN        => 20;
Readonly my $DEFAULT_PER_PAGE      => 200;
Readonly my $MAX_PER_PAGE          => 2_000;

# ---------------------------------------------------------------------------
# Protected helpers
# ---------------------------------------------------------------------------

# _pagination($self) -> ($page, $per_page, $offset)
#
# Reads ?page= and ?per_page= from the current request, clamps them to sane
# ranges, and returns the 1-based page number, the page size, and the
# 0-based row offset.  Pagination is optional: when neither param is present
# per_page defaults to DEFAULT_PER_PAGE.
sub _pagination {
	my ($self) = @_;
	my $per_page = int($self->param('per_page') // $DEFAULT_PER_PAGE);
	$per_page = 1         if $per_page < 1;
	$per_page = $MAX_PER_PAGE if $per_page > $MAX_PER_PAGE;
	my $page = int($self->param('page') // 1);
	$page = 1 if $page < 1;
	my $offset = ($page - 1) * $per_page;
	return ($page, $per_page, $offset);
}

# _i18n($self, $key, @sprintf_args) -> $string
#
# Purpose: Look up a user-visible string from %MESSAGES and apply sprintf
#          for positional arguments.  The entry point for future i18n
#          backend integration (e.g. Locale::Maketext).
# Entry:   $key must be a key in %MESSAGES; @args are sprintf positionals.
# Exit:    Returns the formatted string, or a fallback containing $key.
sub _i18n :Protected ($self, $key, @args) {
	my $tmpl = $MESSAGES{$key} // return "Internal error: unknown message key '$key'";
	return @args ? sprintf($tmpl, @args) : $tmpl;
}

# _is_safe_url($url) -> bool
#
# Purpose: SSRF guard.  Blocks the most dangerous classes of Server-Side Request
#          Forgery targets: loopback aliases (localhost, 127/8) and literal
#          private/link-local/CGNAT IPv4 addresses in the URL host component.
#
# Design rationale for scope:
#   Resolving hostnames via DNS and checking the result is ineffective: a
#   separate DNS lookup happens at LWP connect time (TOCTOU / DNS rebinding
#   window).  The authoritative defence against hostname-based SSRF is a
#   network-layer egress firewall.  This function handles the Perl-layer
#   interception for the most common patterns that operators cannot easily
#   filter at the network level: bare loopback aliases and literal private IPs
#   hard-coded by an attacker.
#
# Blocked targets:
#   localhost / 127.0.0.0/8 / 0.0.0.0 / ::1  -- loopback aliases
#   10.0.0.0/8    -- RFC 1918 private (literal IP)
#   172.16.0.0/12 -- RFC 1918 private (literal IP)
#   192.168.0.0/16-- RFC 1918 private (literal IP)
#   169.254.0.0/16-- link-local; AWS/GCP/Azure metadata endpoint (literal IP)
#   100.64.0.0/10 -- CGNAT / Tailscale shared space (literal IP)
#
# Hostname-based targets (e.g. http://internal.corp.example.com/) are allowed
# at this layer; block them with egress firewall rules instead.
sub _is_safe_url {
	my $url = $_[0];

	return 0 unless $url =~ m{\Ahttps?://([^/:?\[\]#]+)}i;
	my $host = lc $1;

	# Block well-known loopback aliases.
	return 0 if $host eq 'localhost'
	          || $host =~ /\A127\./
	          || $host eq '0.0.0.0'
	          || $host eq '::1';

	# For literal IPv4 addresses only: check private/link-local/CGNAT ranges.
	# Skipping DNS for hostname targets avoids a live network call in tests and
	# removes the TOCTOU window that makes DNS-resolved checks illusory anyway.
	return 1 unless $host =~ /\A\d{1,3}(?:\.\d{1,3}){3}\z/;

	my $packed = inet_aton($host) or return 1;
	my $n = unpack 'N', $packed;

	return 0 if ($n & 0xFF000000) == 0x0A000000;	# 10/8
	return 0 if ($n & 0xFFF00000) == 0xAC100000;	# 172.16/12
	return 0 if ($n & 0xFFFF0000) == 0xC0A80000;	# 192.168/16
	return 0 if ($n & 0xFFFF0000) == 0xA9FE0000;	# 169.254/16 (link-local / metadata)
	return 0 if ($n & 0xFFC00000) == 0x64400000;	# 100.64/10 (CGNAT)
	return 1;
}

# _safe_back_url($url) -> $url | undef
#
# Purpose: Sanitise a user-supplied back-link URL before it is passed to the
#          template and rendered inside an href="" attribute.  Template Toolkit's
#          | html filter encodes <>&" but does NOT block the javascript: or
#          data: URI schemes, so a raw param('back') = "javascript:alert(1)"
#          would be rendered verbatim inside the href and execute on click.
#
# Rule:    Only absolute http(s) URLs and root-relative paths (starting with /)
#          are permitted.  Anything else (javascript:, data:, vbscript:, empty
#          strings, undef) returns undef so the template renders no href.
#
sub _safe_back_url {
	my ($url) = @_;
	return undef unless defined $url && length($url);
	return $url if $url =~ m{\A(?:/(?!/)|https?://)}i;
	return undef;
}

# _resolve_template($self) -> ($platform, $language)
#
# Purpose: Read platform and language from config, then resolve the Accept-Language
#          header to a language code -- falling back to the config default when
#          no template directory exists for the resolved language.
# Exit:    Returns ($platform, $language) -- both guaranteed non-empty strings.
sub _resolve_template :Protected ($self) {
	my $conf         = $self->app->config();
	my $cfg_platform = $conf->{platform} // 'web';

lib/Database/BI/Controller/Dashboard.pm  view on Meta::CPAN

  Column not found: <name>               Named column absent from the result set.
  Could not open data source             Left-table spec unresolvable or 404.
  No plottable data: ...                 All rows lack a valid numeric value.

=head3 EXAMPLE

  GET /pie?l=table:sales&cat=region&val=amount&back=/view/sales

Renders a pie chart of total C<amount> per C<region>.  Clicking the
"North" slice navigates to C</view/sales?f=region:eq:North>.

With a donut hole:

  GET /pie?l=table:sales&cat=region&val=amount&donut=1

=head3 FORMAL SPECIFICATION

  pie_view : Controller x Params -> HTML | Error

  let pipeline = run_export_pipeline(l, j, f, d)
  let totals   = { cat => sum { val(row) | row in pipeline.records,
                                           cat(row) != '', num(val(row)) != bot }
                 | cat in range(cat_col) }

  pre  cat in params /\ val in params     else 400
  pre  cat in pipeline.cols               else 400
  pre  val in pipeline.cols               else 400
  pre  totals != {}                        else 200 "No plottable data"
  post HTML::D3->render_pie_chart_snippet(slices) embedded in TT layout
       where slices = sort_desc { |s.value| | s in totals }

=cut

=head2 clear_uploads

C<POST /uploads/clear> -- Delete every file from the C<.uploads/> staging
directory and return the total disk space recovered.

The C<.uploads/> directory accumulates files from drag-and-drop uploads.
It grows indefinitely; this action lets users reclaim that space without
needing shell access.

=head3 API SPECIFICATION

=head4 INPUT

No parameters.  The C<.uploads/> path is always the one under C<app->home>.

=head4 OUTPUT

  200 application/json   { "freed": <bytes>, "count": <n> }

C<freed> is the total number of bytes removed.  C<count> is the number of
files deleted.  Both are 0 when the directory is absent or already empty.

=cut

=head1 DESCRIPTION

All user-facing routes in C<Database::BI> are handled by this controller.
See the individual action POD above for per-endpoint documentation.

=head2 Filter operators and the quick-filter bar

Filters are expressed as C<f=col:op:val> query parameters and are applied
server-side after all joins.  Multiple C<f=> params are applied in order
(AND semantics).

The colon separator is split with a limit of 3, so values may themselves
contain colons (e.g. C<f=sale_date:eq:2025-01-15>, or a time value like
C<f=start_time:eq:14:30:00>).

B<Supported operators:>

  eq        case-insensitive string equality
  ne        case-insensitive string inequality
  contains  case-insensitive substring match
  starts    case-insensitive prefix match
  lt        numeric less-than
  le        numeric less-than-or-equal
  gt        numeric greater-than
  ge        numeric greater-than-or-equal
  empty     cell is undef or empty string (val ignored)
  notempty  cell is defined and non-empty (val ignored)

B<Quick-filter bar (table view):>

A filter row below the toolbar lets the user pick a column, operator, and
value and press "Apply filter" (or Enter) to add a condition without
opening any panel.  The current URL is updated with the new C<f=> param
and the page reloads; all other URL parameters (joins, dedup, etc.) are
preserved automatically via the browser C<URL> API.

Active filter chips appear in the toolbar.  Each chip has an individual
x-button to remove just that condition.  The "x Clear filters" link
removes all C<f=> params at once.

B<Drill-down from pie chart:>

Clicking a pie slice or legend entry on C</pie> navigates to the table
view with C<f=cat_col:eq:label> appended, so the user can inspect the
rows behind any slice without manually typing a filter.

=head1 COMMON PITFALLS

These are the most common mistakes when working with this controller.

=over 4

=item B<SQLite files must use the .sql extension, not .sqlite>

C<Database::Abstraction> (the data-reading library) probes for a file called
C<tablename.sql> when it wants to open a SQLite database.  It does B<not>
look for C<.sqlite> or C<.db3>.  If your file is called C<inventory.sqlite>,
rename it to C<inventory.sql> or it will not appear in the file browser and
will return 404 when opened.

=item B<Filter values that contain a colon still work>

A date like C<2025-01-15> contains hyphens, not colons, so it is fine.  But
if your value itself contains a colon (for example, a time like C<14:30:00>),



( run in 1.209 second using v1.01-cache-2.11-cpan-cb04b646d8c )