DEV Community

DragosTrif
DragosTrif

Posted on • Edited on

4

Mojolicious and Docker part 2

I my last post mojolicious-and-docker I have explained how to build and Docker image and to spin up which holds an Mojo app in this one I want to add show how to:

  1. Add an data base image to the docker compose file
  2. Create an network between the and the app
  3. Create and persists your db
  4. Start the Docker in the containers in the correct order

So lets start.
In the compose.yaml add the following lines:

database:
    image: mariadb
    ports: 
      - "3306:3306"
Enter fullscreen mode Exit fullscreen mode

This code snippet will the db image.
Now add the following lines at the same level with ports in the compose file.

environment:
      MARIADB_ROOT_PASSWORD: ${DB_PASSWORD}
      MARIADB_DATABASE: ${DB_NAME}
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_USER: ${DB_USER}
Enter fullscreen mode Exit fullscreen mode

This value are red from the .env file defined in at the same level with the compose file. The content of that files is similar to this:

MARIADB_ROOT_PASSWORD=<your_password>
DB_HOST=<database>
DB_USER=<your_password>
DB_PASSWORD=<your_password>
DB_NAME=<my_app>
Enter fullscreen mode Exit fullscreen mode

As side note on local machine I make this entry into '/etc/hosts' to be able to connect to the db from outside the Docker container.

127.0.0.1   database
Enter fullscreen mode Exit fullscreen mode

Because you do not want to lose date mount the db files for persistence using this line:

    volumes: 
      - ./maria-data:/var/lib/mysql
Enter fullscreen mode Exit fullscreen mode

At this point you have an Maria db image that we know it will not lose data but nothing is actually in there. Lets address this by mounting an new volume:

- ./my_app/migrations/000_create_db.sql:/docker-entrypoint-initdb.d/000_create_db.sql
Enter fullscreen mode Exit fullscreen mode

Open an editor and this lines in the 000_create_db.sql script:

CREATE DATABASE IF NOT EXISTS my_app
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;
Enter fullscreen mode Exit fullscreen mode

This will run and create the db when you run docker compose up.
In order to make sure that everything cheeks out we need to: add health check, create and network and make sure that the app starts after the db:

So to add the health check drop this lines

healthcheck:
      test: ["CMD-SHELL", "mariadb -u${DB_USER} -p${DB_PASSWORD} -e 'SELECT 1'"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
Enter fullscreen mode Exit fullscreen mode

Then both services database and web and this line:

networks:
      - my-app-net
Enter fullscreen mode Exit fullscreen mode

Then at the bottom of the file define the network:


networks:
  my-app-net:
Enter fullscreen mode Exit fullscreen mode

To make sure that the app wont break add this in the web service:

 depends_on:
      database:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

This will ensure that the app will start only after the db.
At this point we can start to think about integrating the db in into our app. First thing is first lets add all the Perl libraries into the cpan file:

requires 'File::Slurper'                        => '0.014';
requires 'Mojolicious'                          => '9.39',;
requires 'Dotenv'                               => '0.002';
requires 'DBI'                                  => ' 1.647'; 
requires 'DBD::MariaDB'                         => '1.23';
requires 'Dotenv'                               => '0.002';
requires 'Try::Tiny'                            => '0.32';
requires 'Digest'                               => '1.20';
requires 'Digest::SHA'                          => '6.04';
requires 'Mojolicious::Plugin::Authentication'  => '1.39';
requires 'Digest::Bcrypt'                       => '1.212';
requires 'DBD::Mock::Session::GenerateFixtures' => '0.11';
Enter fullscreen mode Exit fullscreen mode

Now run carton install locally this will update the the cpanfile.snapshot and take of the dependencies.
Now if we type docker compose build an new image will be create with all the requires libraries baked in.
Now spin the composition with docker compose up
You should see something similar with this:

 ✔ Container my_app-database-1  Created                                                                                                                                                            0.0s 
 ✔ Container my_app-web-1       Created
Enter fullscreen mode Exit fullscreen mode

No its time to put some meat and the bones at some Perl code in our app and some tables we want to use:
So create this table in the db:


CREATE TABLE IF NOT EXISTS users (
    id MEDIUMINT NOT NULL AUTO_INCREMENT,
    plugin VARCHAR(30) NOT NULL DEFAULT 'Auth',
    username VARCHAR(30) UNIQUE NOT NULL,
    user_password VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
    salt VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
    is_admin TINYINT(1) NOT NULL DEFAULT '0',
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE INDEX idx_plugin ON users(plugin);
CREATE INDEX idx_username ON users(username);
CREATE INDEX idx_user_password ON users(user_password);
Enter fullscreen mode Exit fullscreen mode

Now in the main controller add this lines MyApp.pm inside sub start up:


  # Router
  my $r = $self->routes;

  # # # Normal route to controller
  $r->get('/')->to('Example#welcome');

  #load the login form
    $r->get('/login')->to(
        controller => 'Login',
        action     => 'login'
    );

    #submit the login form
    $r->post('/login')->to(
        controller => 'Login',
        action     => 'user_login'
    );

    my $auth_required = $r->under('/')->to('Login#user_exists');

    $auth_required->get('/welcome')->to(
        controller => 'User', action => 'welcome',
    )
Enter fullscreen mode Exit fullscreen mode

Now lets add an the login link to templates/example/welcome.html.ep:


<h2><%= $msg %></h2>
<p>
  <%= link_to 'here' => '/login' %> to login.
</p>
Enter fullscreen mode Exit fullscreen mode

Now lets define the login controller by creating this file:

package My_App::Controller::Login;
use Mojo::Base 'Mojolicious::Controller', -signatures;

use Data::Dumper;
use Digest;
use MIME::Base64;
use Mojolicious::Plugin::Authentication;

use lib 'lib';
use DBI;
use DBD::MariaDB;

my $dsn = "DBI:MariaDB:database=$ENV{DB_NAME};host=$ENV{DB_HOST};port={$ENV{DB_PORT}";
my $dbh = DBI->connect($dsn, $ENV{DB_USER}, $ENV{DB_PASSWORD});
sub login($self) {

    $self->render(
        template => 'login',
        error    => $self->flash('error')
    );
}

sub user_login($self) {

    # From the form
    my $password = $self->param('password');
    my $username = $self->param('username');

    # auth plugin setup
    $self->app->plugin(
        'authentication' => {
            autoload_user => 1,
            wickedapp     => 'YouAreLogIn',
            load_user     => sub($c, $user_id) {
                if ($user_id) {
                              my $query = "<<SQL";
                                   SELECT t1.id, 
                                    t1.username, 
                                    t1.user_password, 
                                    t1.salt, 
                                    t1.is_admin 
                                 FROM users t1 WHERE t1.id = ?
SQL
                                my $sth = $dbh->prepare($query);
                                $sth->execute($user_id);
                            my $user = $sth->fetchrow_hashref();
                                return $user
                   }

                return;
            },
            validate_user => sub($c, $user, $pass, $extradata) {
                my $user_key = $self->validate_user_login($user, $password);
            }

        }
    );

    my $auth_key = $self->authenticate($username, $password);

    if ($auth_key) {
        $self->flash(message => 'Login Success.');
        $self->session(user => $auth_key);
        return $self->redirect_to('/welcome');
    } else {
        $self->flash(error => 'Invalid username or password.');
        $self->redirect_to('login');
    }

}

# validate the user login
sub validate_user_login {
    my ($self, $username, $password, $extradata) = @_;

               SELECT t1.id, 
                      t1.username, 
                      t1.user_password, 
                      t1.salt, 
                      t1.is_admin 
              FROM users t1 WHERE t1.username = ?
SQL
        my $sth = $dbh->prepare($query);
        $sth->execute($password);
     my $user = $sth->fetchrow_hashref();

    my $id           = $user->{id};
    my $db_password  = $user->{user_password};
    my $salt         = $user->{salt};


    if (!defined $id) {
        return 0;
    } else {
        return validate_password($password, $db_password, $salt) ? $id : undef;
    }

    return 1;
}


sub validate_password($form_password, $db_password, $salt) {

    # $salt   = decode_base64($salt);
    my $cost   = 12;
    my $bcrypt = Digest->new(
        'Bcrypt',
        cost => $cost,
        salt => decode_base64($salt)
    );

    if ($bcrypt->add($form_password)->b64digest() eq $db_password) {
        return 1;
    }

    return 0;
}

sub user_exists($c) {
    if ($c->session('user')) {
        return 1;
    } else {
        $c->redirect_to('login');
    }
}

1;

Enter fullscreen mode Exit fullscreen mode

Now lets add the login form:

<div class="container">
    <div class="card col-sm-6 mx-auto">
        <div class="card-header text-center">
            User Sign In 
        </div>
        <br /> <br />
        <form method="post" action='/login'>
            <input class="form-control" 
                   id="username" 
                   name="username" 
                   type="text" size="40"
                   placeholder="Enter Username" 
             />
            <br /> 
            <input class="form-control" 
                   id="password" 
                   name="password" 
                   type="password" 
                   size="40" 
                   placeholder="Enter Password" 
             />     
            <br /> 
            <input class="btn btn-primary" type="submit" value="Sign In">
            <br />  <br />
        </form>

        % if ($error) {
            <div class="error" style="color: red">
                <small> <%= $error %> </small>
            </div>
        %}
    </div>

</div>

    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Lets refactor the db connection a bit by creating an DB package in my_app/lib folder.


package MyDatabase;

use strict;
use warnings;

use DBI;
use DBD::MariaDB;
use Carp 'croak';
use Exporter::NoWork;

sub db_handle {

  my $dsn = "DBI:MariaDB:database=$ENV{DB_NAME};host=$ENV{DB_HOST};port={$ENV{DB_PORT}";
  my $dbh = DBI->connect($dsn, $ENV{DB_USER}, $ENV{DB_PASSWORD});
 return $dbh;
}
Enter fullscreen mode Exit fullscreen mode

At this point we can use an small script to insert an user in our db:

use strict;
use warnings;

use Digest::SHA qw(sha256_base64);
use Digest;
use MIME::Base64;
use lib 'lib';
use MyDatabase qw(db_handle);
use feature 'say';
my $salt = sha256_base64(time . rand() . $$);
$salt = substr($salt, 0, 16);
chomp $salt;
my $cost = 12;
my $bcrypt = Digest->new('Bcrypt', cost => $cost, salt => $salt);
my $sql = <<"SQL";
INSERT INTO users (
        salt,
        username,
        user_password,
         is_admin)
VALUES ( ?, ?, ?, ?)
SQL

 my $dbh = db_handle();
 my $r = $dbh->do($sql_license, undef, encode_base64($salt), 'Diana', $bcrypt->add('password')->b64digest(), 0);
Enter fullscreen mode Exit fullscreen mode

Remember to add the dependencies to the carton file, use the new lib in Login controller and run the script to insert the new user.
Login in the app should be possible at this moment.
The last thing we need to do is provide an unit test for our app for this create an file in the t folder of you app with the following content:

use Mojo::Base -strict;

use Test2::V0;
use Test::Mojo;


use DBI;
use Dotenv;
use DBD::Mock::Session::GenerateFixtures;
use Sub::Override;
use MyDatabase qw(db_handle);
use lib 'lib';

use feature 'say';

my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new({dbh => db_handle() });
my $t = Test::Mojo->new('MyApp');
$t->get_ok('/')->status_is(200);
my $login_url = $t->tx->res->dom->find('a')->grep(qr/login/)->map(attr => 'href')->first;
$t->post_ok($login_url => form => { username => 'Diana', password => 'password' })->status_is(200)
done_testing();
Enter fullscreen mode Exit fullscreen mode

With your server up so it will connect to the run the unit test this will produce an mock file like this one:

[
   {
      "results" : [
         [
            1,
            "Diana",
            "IDRBjhp5KGuSQ8gqpc8bzSdU898DMK0",
            "eFV0eC96YWpJMUJwU3h2WA==",
            0
         ],
         []
      ],
      "statement" : "SELECT t1.id, t1.username, t1.user_password, t1.salt, t1.is_admin FROM users t1 WHERE t1.username = ?",
      "bound_params" : [
         "Diana"
      ],
      "col_names" : [
         "id",
         "username",
         "user_password",
         "salt",
         "is_admin"
      ]
   },
   {
      "bound_params" : [
         1
      ],
      "statement" : "SELECT id, username, user_password, salt, is_admin FROM users WHERE id = ?",
      "results" : [
         [
            1,
            "Diana",
            "IDRBjhp5KGuSQ8gqpc8bzSdU898DMK0",
            "eFV0eC96YWpJMUJwU3h2WA==",
            0
         ],
         []
      ],
      "col_names" : [
         "id",
         "username",
         "user_password",
         "salt",
         "is_admin"
      ]
   }
]

Enter fullscreen mode Exit fullscreen mode

After this done do docker compose down and then edit the unit test:

my $override = Sub::Override->new();
# remove the dbh as an argument
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new();
# replace the dbh with the mocked one
$override->replace('MyDatabase::db_handle' => sub {return  $mock_dumper->get_dbh});
Enter fullscreen mode Exit fullscreen mode

Now you should be able to run unit test isolated from the world wide web

Top comments (0)

Image of Datadog

Get the real story behind DevSecOps

Explore data from thousands of apps to uncover how container image size, deployment frequency, and runtime context affect real-world security. Discover seven key insights that can help you build and ship more secure software.

Read the Report

👋 Kindness is contagious

Dive into this thoughtful article, cherished within the supportive DEV Community. Coders of every background are encouraged to share and grow our collective expertise.

A genuine "thank you" can brighten someone’s day—drop your appreciation in the comments below!

On DEV, sharing knowledge smooths our journey and strengthens our community bonds. Found value here? A quick thank you to the author makes a big difference.

Okay