RSS Git Download  Clone
Raw Blame History
package DocsLib;

# uses Plugin::Auth::Tiny - registers keyword 'needs', which is a coderef that
# checks for 'user' key in session, or redirects to login page
# extended in App::LogAnyAdaper to use 'admin' keyword

use 5.34.0; # say
use DateTime;
use Path::Tiny;
use Data::Printer;
use Scalar::Util qw(reftype blessed);

use Dancer2;
use Dancer2::Plugin::Auth::Tiny; 
use Dancer2::Plugin::CryptPassphrase; # this is (deliberately) slow due to hashing

use App::AuthTinyExtend; # registers 'admin' keyword

use App::LogAnyAdapter;  # allows model to use D2 debugging function
App::LogAnyAdapter::init();

use constant PERLVERSION => $^V;
use if PERLVERSION lt v5.36.0, experimental => 'signatures'; # but not for routes

set auto_page => 1;

use App::Model; # D2 plugin, provides 'model' keyword
our $VERSION = model->app_version; say "App version: $VERSION";

use Module::List::Pluggable 'import_modules'; # import all Routes::<class>
import_modules( 'Routes', { exceptions => [] } );

prefix undef;

# routes =======================================================================
get  index         => '/'              => \&index;
get  gitlog        => '/gitlog'        => \&gitlog;
get  logout        => '/logout'        => \&logout;
get  download      => '/download'      => \&download_file;
get  login_page    => '/login'         => \&login_page;
get  routes_list   => '/routes'        => \&routes_list;
get  total_count   => '/total_count'   => \&total_count;
post auth_login    => '/login'         => \&authenticate_login; 

# for requests without trailing slash:
get '/dpw'      => sub { redirect uri_for_route('dpw_home') };
get '/test'     => sub { redirect uri_for_route('test_home') };
get '/infolib'  => sub { redirect uri_for_route('infolib_home') };
get '/moongate' => sub { redirect uri_for_route('moongate_home') };
# ==============================================================================

hook before => sub { # my $s = session(); p $s;
	debug '-' x 12 . ' new request ' . '-' x 11;
	debug session->data;
    # debug request->headers;
    # my $r = request; debug "$_: $r->{request}->{$_}" for grep { $_ =~ /hx/ } keys %{ $r->{request} };
    var is_hx_request => my $is_hx_req = request->header('HX-Request') ? 1 : 0;
	var hx_request => { # in case we ever need to use these downstream
		current_url => request->header('HX-Current-URL') || undef,
		trigger     => request->header('HX-Trigger')     || undef, 
		target      => request->header('HX-Target')      || undef, 
	}; # debug 'is_hx-req:'.$is_hx_req; debug var 'hx_request' if $is_hx_req;
    # setting layout => $is_hx_req ? undef : 'main'; # not required, now SPA
	# debug 'session expires in '.(session->expires - DateTime->now->epoch).' sec';
};

hook after => sub { debug session->data }; # to show if session still active

hook before_template_render => sub { # debug session->expires;
    my $tokens = shift;
	$tokens->{app_version} = $VERSION;
    $tokens->{total_count} = sub { model->total_count(@_) };
	$tokens->{apphandler}  = config->{apphandler}; # missing from settings in tt
	$tokens->{session_object}  = session(); # tt only has access to data() section of session
    $tokens->{session_expires} = _from_epoch( session->expires ); # p $tokens;
};

# route subs ===================================================================
sub login_page { # my $p = params(); p $p;
    my $return_url = query_parameters->get('return_url'); # set by Auth::Tiny
    template login => { return_url => $return_url }; # { layout => 'main' };
}

sub authenticate_login { # my $p = params(); p $p;
    my $password   = body_parameters->get('password');
    my $return_url = body_parameters->get('return_url'); # debug $return_url;

	if ( my $username = _authenticate_user($password) ) {
        app->change_session_id;
        session user => $username; # Plugin::Auth::Tiny requires session id = 'user'
        if ( my $admins = config->{admins} ) { # p $admins;
            session is_admin => ( grep $username eq $_, @{ $admins } ) ? 1 : 0;
        }
        info "$username successfully logged in";
        redirect $return_url || '/';
    }
    else {
		$username = body_parameters->get('username') || 'unknown user';
        warning "failed login attempt for $username";
        template login => { login_error => 1, return_url => $return_url };
    }
}

sub total_count {
    my $section = query_parameters->get('section'); # p $section;
    return model->total_count( $section );
}

sub gitlog { template gitlog => { log => model->gitlog } };

# this isn't needed except for testing:
sub logout {
    my $prefix = query_parameters->get('prefix'); # debug $prefix;
    app->destroy_session;
    redirect $prefix;
}

sub index { template index => {}, { layout => 'default' } }

sub download_file {
    # document_id & download_dir set in forwarding route
    my $doc = var 'document_id'  or send_error 'no document id passed', 500;
    my $dir = var 'download_dir' or send_error 'no download dir specified', 500;
    # normalize to absolute path unless already:
    $dir = $dir->absolute( config->{appdir} ) unless $dir->is_absolute;

    my $file = $dir->child($doc); debug "Resolved file path: $file";

    my $util = App::Utils->new; # validator returns 'OK', or arrayref
    my $validation = $util->validate_download_request($dir, $doc, $file);
    if ( ref $validation eq 'ARRAY' ) {
        my ($error, $code) = @$validation;
        warning "failed validation of download: $doc ($error)";
        send_error $error, $code;
    }
    send_file $file->stringify, system_path => 1;
}

sub routes_list {
    my $routes = app->routes; # p $routes;

    my @list;

    for my $method (sort keys %$routes) { # debug $method;
        next if $method eq 'head'; # not needed
        for my $route (@{ $routes->{$method} }) { # debug $route->name;
            my $handler = $route->code;
            my $handler_name = _coderef_name($handler);

            push @list, {
                method  => uc $method,
                name    => $route->name,
                path    => $route->spec_route,
                # handler => $handler_name, # not useful
            };
        }
    }
    template routes => { routes => \@list };
    # return to_json(\@list);   # needs Dancer2::Plugin::JSON or JSON::MaybeXS
}

# default route for "not found" (need to exempt routes using auto_page)
any qr{^(?!/prohibited).*} => sub {
    status 'not_found';
	my $prefix = ( split '/', request->path )[1]; # debug $prefix; # 2nd element
    template 404 => { prefix => $prefix };
};

# non-route subs ===============================================================
sub _from_epoch {
    my $int = shift || return; # no session, will fatal on from_epoch() if undef
    DateTime->from_epoch( epoch => $int, time_zone => 'Europe/London' );
}

sub _coderef_name {
    my ($code) = @_; # p $code;
    return 'anonymous' unless $code;

    if (blessed($code) && $code->can('name')) {
        return $code->name;  # some wrapped subs expose names
    }

    # crude fallback: stringify the coderef
    return "$code";
}

#  using PBKDF2 for development, faster but less secure than Argon2 for deployment
sub _authenticate_user ($password) {
	my $username = body_parameters->get('username');

	if ( grep app->environment eq $_, qw/development test/ ) { # use faster PBKDF2 method
		my $user = setting 'user'; # in development_local.yml
		$username ||= $user->{name}; # don't need to provide it for dev
		return $username if model->verify_password( $password, $user->{pwd} );
	}
	else { # Plugin::CryptPassphrase (uses more secure argon2 auth; see ~/scripts/crypt_passphrase.pl)
		my $user = model->find_user($username);
		return $username if verify_password( $password, $user->{password} );
	}
	return undef; # failed authentication
}

true;