#!/usr/bin/env perl use Data::Printer; use DBI; use DateTime; use Minion::Backend::SQLite; use Mojo::Log; use Mojolicious::Lite -signatures; use Time::Piece; use Time::Seconds; use Config::IniFiles; use Digest::SHA qw(sha512_base64); =encoding utf8 =head1 NAME Komenvatil - A real-time opinion collecting application =head1 DESCRIPTION Komenvatil makes possible to collect opinions about a collective situation and build display simple reports. It provides and API and a friendly front-end. =head1 INSTALLATION Setting up the minimal environment: $ apt install cpanminus $ cpanm App::Perlbrew $ perlbrew init Installing Perl separately from system's Perl: $ perlbrew install perl-5.28.1 $ perlbrew alias create perl-5.28.1 komenvatil $ perlbrew use komenvatil Re-installing Cpanminus locally: $ perlbrew install-cpanm Installing Carton: $ cpanm Carton Setting-up the app: $ git clone https://gitlab.com/smonff/komenvatil $ cd komenvatil/server $ carton install Starting the app in production $ make run-prod =head1 CONFIGURATION =cut my $cfg = Config::IniFiles->new( -file => "./conf.ini" ); my $log = Mojo::Log->new; my $dbname = "./data/" . $cfg->val(db => 'name'); my $version = $cfg->val(app => 'version'); app->config(hypnotoad => {listen => ['http://*:8000']}); helper dbh => sub { state $dbh = DBI->connect("dbi:SQLite:dbname=$dbname","",""); }; =head1 PLUGINS =head2 Minion =cut plugin Minion => {SQLite => "sqlite:$dbname"}; plugin 'Minion::Admin'; =head2 Cron =cut my $expiration = $cfg->val(cron => 'frequency'); plugin Cron => ( # Should continue to expire until the last remaining votes "*/$expiration 6-24 * * *" => sub { my @expirated = get_expirated_votes(); if (@expirated > 0) { $log->warn('Purging'); app->minion->enqueue(expirate => [ @expirated ]); app->minion->perform_jobs; } else { $log->warn('Nothing to purge')} } ); =head2 Documentation =cut plugin 'DOCRenderer' => {url => '/doc'}; =head2 Yancy =cut plugin Yancy => { backend => "sqlite:$dbname", read_schema => 1, collections => { votes => { 'x-list-columns' => [ 'id', 'mood', 'date', 'active' ], }, users => { 'x-list-columns' => [ 'id', 'username', 'password' ], }, } }; app->yancy->plugin( 'Auth::Basic' => { collection => 'users', username_field => 'username', password_field => 'password', password_digest => { type => 'SHA-512', }, }); splash(); =head1 ROUTES The routes under C returns JSON ready to be consummed by the front-end. =head2 Home GET / Index with some useful infos. Could be protected so we could link Minion, Yancy and other helper super secret routes. =cut get '/' => sub ($c) { $c->render(template => 'index'); }; =head2 Cors headers All the routes under this one should be prefixed by C. GET /api/votes GET /api/votes/expirated Allow CORS origin * for the API. All the above functions will have the "access-control-allow-origin: *" response header available, what allows requests of the API from any client. =cut under '/api' => sub ($c) { $c->res->headers->access_control_allow_origin('*'); $c->res->headers->append('Access-Control-Allow-Headers' => 'Content-Type'); $c->res->headers->append('Access-Control-Allow-Methods' => 'GET, POST, OPTIONS, DELETE'); }; =head2 Retrieving the application infos GET /api Display the application version for now. =cut get '/' => sub ($c) { $c->render(json => { version => $version, }); }; =head2 Getting the votes GET /api/votes Retrieve all actives votes in a structured JSON array and some calculated informations to be used on the front-end. { "average": 50, "mood": { "happy": 1, "meh": 0, "sad": 1 }, "version": "0.55", "votes": [ { "date": "2019-01-01T11:53:23", "vote":100 }, { "date": "2019-01-01T11:53:25", "vote": 0 } ] } =cut get '/votes' => sub ($c) { my $sth = app->dbh->prepare( 'SELECT mood, date FROM VOTES WHERE active = 1'); my (@results, $sum); $sth->execute(); while (my @row = $sth->fetchrow_array) { push @results, {vote => $row[0], date => $row[1]}; $sum += $row[0]; } p @results; my $mood = get_mood(@results); # Return zero if nobody answered yet my $average = @results ? get_score($sum, @results) : 0; my $data = { votes => \@results, average => $average, mood => $mood, version => $version, }; $c->render(json => $data); }; =head2 Voting POST /api/vote OPTIONS /api/vote Post the actual vote by retrieving it from a JSON body POST request containing a C parameter. The same C route is also required to allow Axios to post the vote. Makes the C post possible. =cut post '/vote' => sub ($c) { my $vote = $c->req->json->{score}; $log->info("Did vote with a score of $vote"); # No local is passed so it default to en-US (AAAA-MM-DDThh:mm:ss) my $now = DateTime->now; # TODO check integrity (type, max value) my $sth = app->dbh->prepare( 'INSERT INTO VOTES (mood, date) VALUES (?, ?)'); my $result = $sth->execute($vote, $now); my $data = {value => $vote}; $c->render(json => $data); }; options '/vote' => sub ($c) { $c->render(json => {nothing => ''}); }; =head2 Testing votes post using a GET request This is a very handy route that accept a vote value and an optional date thanks to Mojolicious wilcard placeholders. It makes possible to pass an optional date (yyyy-mm-dd format is S) for testing the expiration. GET /api/vote/*values More explicitely, it means you can ask for: GET /api/vote/:value GET /api/vote/:value/:date For testing purposes, not the actual voting sub. See C. =cut get '/vote/*values' => sub ($c) { my $datetime = DateTime->now; my @params = split('/', $c->param('values'), 2); # If we have more parameters, we will die if ( scalar @params <= 2 ) { # Optional if no date provided in the route if ( defined $params[1] ) { my @dates_params = split('-', $params[1], 3); my %datetime = ( year => $dates_params[0], month => $dates_params[1], day => $dates_params[2], ); $datetime = DateTime->new(%datetime); } my %vote = ( value => $params[0], datetime => $datetime, ); $log->info("Did vote with a score of $vote{value}"); my $sth = app->dbh->prepare( 'INSERT INTO VOTES (mood, date) VALUES (?, ?)'); my $result = $sth->execute($vote{value}, $vote{datetime}); my $data = { value => $vote{value}, date => $datetime }; $c->render(json => $data); } else { $c->reply->exception('Oops!'); die 'Too many parameters are passed, check what you asked for'; } }; =head2 Retrieving expirated votes GET /api/vote/expirated Those will be passed to Minion. =cut get '/votes/expirated' => sub ($c) { my @exp = get_expirated_votes(); $c->render(json => { count => scalar @exp, expirated => \@exp, }); }; =head2 Creating a hashed password for a user GET /create/user/:name/:pass Copy paste the result from the logs to the database for creating users. If you put the value in JSON, some problems could happend with escaped '/' =cut get '/create/user/:name/:pass' => sub ($c) { my $digested_pass = sha512_base64( $c->param('pass') ); $log->info($digested_pass); $c->render(json => { 'sha-512' => 'Password for ' . $c->param('name') . '. Copy paste the result from the logs into the database' }); }; =head1 HELPERS Most of them are helpers that build data used by the routes. They should be turned to real Mojolicious helpers. =head2 get_score() Calculate the current score, the mean of all votes, the current I. use Mojolicious::Lite; get '/votes' => sub ($c) { my (@results, $sum); @results = $dbi->get_results(); my $average = get_score($votes_scores_sum, @results) ... } =cut sub get_score ($sum, @results) { return $sum / scalar @results; } =head2 get_expirated_votes() Select current active votes in the database and check which one are elligible for the expiration. =cut sub get_expirated_votes { my @expirated_votes; my $now = DateTime->now; my $sth = app->dbh->prepare( 'SELECT id FROM votes WHERE date <= ? and active = ?' ); my $frequency_selector = $cfg->val(votes => 'frequency'); my $frequency_value = $cfg->val(votes => 'expirable'); $sth->execute( $now->subtract( $frequency_selector => $frequency_value ), 1 ); while (my @row = $sth->fetchrow_array ) { push @expirated_votes, @row; } return @expirated_votes; }; =head2 get_mood() Calculate the mean moods for each I, I or I mood. =cut sub get_mood (@results) { my $mood; $mood->{sad} = 0; $mood->{meh} = 0; $mood->{happy} = 0; # When the database is empty, we don't enter the loop. We need this fake # element, so we will enter the foreach loop at least one time if (scalar @results eq 0) { @results[0] = 0; } foreach my $vote ( @results ) { if ($vote == 0) { last; } if ($vote->{vote} eq 0) { $mood->{sad} += 1; } elsif ($vote->{vote} eq 50) { $mood->{meh} += 1; } elsif ($vote->{vote} eq 100) { $mood->{happy} += 1; } } return $mood; } =head2 Add expirate tasks to Minion Takes an array of expirated votes as a parameter and send them to C when Minion think it will be time. =cut app->minion->add_task( expirate => sub { my ($job, @votes) = @_; expirate(@votes); $job->app->log->info(qq{Gonna expirate "@votes"}); } ); =head2 expirate() Helps Minion to get rid if expirated votes. =cut # Set votes passed as parameters to active 0 # Maybe we should delay tasks sub expirate( @votes_ids ) { # https://stackoverflow.com/a/22989868/954777 my $sql = 'UPDATE votes SET active = ? WHERE id IN ('; $sql .= join ',', ("?") x @votes_ids; $sql .= ')'; app->log->debug($sql); my $sth = app->dbh->prepare( $sql ); $sth->execute( 0, @votes_ids ); app->log->info('Expired!') } =head2 splash() An ugly splash ASCII screen =cut sub splash() { $log->info("██ ▄█▀ ▒█████ ███▄ ▄███▓▓█████ ███▄ █ ██▒ █▓ ▄▄▄ ▄▄▄█████▓ ██▓ ██▓"); $log->info("██▄█▒ ▒██▒ ██▒▓██▒▀█▀ ██▒▓█ ▀ ██ ▀█ █▓██░ █▒▒████▄ ▓ ██▒ ▓▒▓██▒▓██▒"); $log->info("▓███▄░ ▒██░ ██▒▓██ ▓██░▒███ ▓██ ▀█ ██▒▓██ █▒░▒██ ▀█▄ ▒ ▓██░ ▒░▒██▒▒██░"); $log->info("▓██ █▄ ▒██ ██░▒██ ▒██ ▒▓█ ▄ ▓██▒ ▐▌██▒ ▒██ █░░░██▄▄▄▄██░ ▓██▓ ░ ░██░▒██░"); $log->info("▒██▒ █▄░ ████▓▒░▒██▒ ░██▒░▒████▒▒██░ ▓██░ ▒▀█░ ▓█ ▓██▒ ▒██▒ ░ ░██░░██████▒"); $log->info("▒ ▒▒ ▓▒░ ▒░▒░▒░ ░ ▒░ ░ ░░░ ▒░ ░░ ▒░ ▒ ▒ ░ ▐░ ▒▒ ▓▒█░ ▒ ░░ ░▓ ░ ▒░▓ ░"); $log->info("░ ░▒ ▒░ ░ ▒ ▒░ ░ ░ ░ ░ ░ ░░ ░░ ░ ▒░ ░ ░░ ▒ ▒▒ ░ ░ ▒ ░░ ░ ▒ ░"); $log->info("░ ░░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ▒ ░ ░ ░"); $log->info("░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░"); $log->info("░"); $log->info(" v.$version"); }; app->secrets(['Ne mets pas tes mains sur la porte'.rand()]); app->start; =head1 Acknowledgments =over =item Ezgi The whole thing is based on her idea and she contributed to all the features. =item Smonff Development =item Le Nid des Poussins Tests =back =cut __DATA__ @@ index.html.ep % layout 'default'; % title 'Welcome';

Welcome to Komenvatil !

To learn more, you can browse through the documentation <%= link_to 'here' => '/doc' %>. @@ layouts/default.html.ep <%= title %> <%= content %>