Set up + run perltidy, plus some manual format changes too

This commit is contained in:
ngoomie 2023-05-17 10:12:48 -06:00
parent fd585edc80
commit 51b2c54e9a
21 changed files with 399 additions and 267 deletions

6
.gitignore vendored
View File

@ -7,3 +7,9 @@ charmboard.conf
# Perl::Critic
perlcritic.log
# Perl::Tidy
perltidy.log
*.tdy
*.pm.LOG
*.pl.LOG

42
.perltidyrc Normal file
View File

@ -0,0 +1,42 @@
-w
-log
-i=2
-l=70
-enc=utf8
-gcs
-ole=unix
-it=2
-ci=4
-xci
-cti=1
-nolq
-nola
-pt=2
-bt=1
-bbt=0
-tso
-nsts
-sfs
-nasc
-dsm
-aws
-dws
-fpva
-lop
-ibc
-nfs
-ce
-nbl
-olc
-dsm
-olq
-kis
-bbb
-kbl=1
-bar
-otr
-bbhb=3
-bbhbi=1
-wn
-vt=1
-nlal

View File

@ -16,48 +16,53 @@ sub startup {
$self->plugin('TagHelpers');
# load configuration from config file
my $config = $self->plugin('Config' =>
{file => 'charmboard.conf'});
my $config =
$self->plugin('Config' => { file => 'charmboard.conf' });
# set this specific forum's name
$self->helper(board_name => sub {$config->{board_name}});
$self->helper(board_name => sub { $config->{board_name} });
# load dev env only stuff, if applicable
if ($config->{environment} eq 'dev') {
$self->plugin('Renderer::WithoutCache');
$self->renderer->cache->max_keys(0)};
$self->renderer->cache->max_keys(0)
}
# import Mojolicious secrets
$self->secrets($config->{secrets});
# import password pepper value
$self->helper(
pepper => sub {$config->{pass_crypt}->{pepper}});
$self->helper(pepper => sub { $config->{pass_crypt}->{pepper} });
## database setup
# ? this could maybe be a given/when
{ my ($_dsn, $_unicode);
{
my ($_dsn, $_unicode);
if ($self->config->{database}->{type} ~~ 'sqlite') {
$_dsn = "dbi:SQLite:" . $config->{database}->{name};
$_unicode = "sqlite_unicode"}
$_dsn = "dbi:SQLite:" . $config->{database}->{name};
$_unicode = "sqlite_unicode"
elsif ($self->config->{database}->{type} ~~ 'mariadb') {
$_dsn = "dbi:mysql:" . $config->{database}->{name};
$_unicode = "mysql_enable_utf"}
} elsif ($self->config->{database}->{type} ~~ 'mariadb') {
$_dsn = "dbi:mysql:" . $config->{database}->{name};
$_unicode = "mysql_enable_utf"
else {die "\nUnknown, unsupported, or empty database type
} else {
die "\nUnknown, unsupported, or empty database type
in charmboard.conf. If you're sure you've set it to
something supported, maybe double check your spelling?
\n\n\t
Valid options: 'sqlite', 'mariadb'"};
Valid options: 'sqlite', 'mariadb'"
}
our $schema = CharmBoard::Schema->connect(
$_dsn,
$config->{database}->{user},
$config->{database}->{pass},
{$_unicode => 1});
{ $_unicode => 1 }
);
$self->helper(schema => sub {$schema})}
$self->helper(schema => sub { $schema })
}
# router
my $r = $self->routes;
@ -65,34 +70,41 @@ sub startup {
# view subforum
$r->get('/subforum/:id')->to(
controller => 'Controller::ViewSubf',
action => 'subf_view');
action => 'subf_view'
);
# controller routes
## index page
$r->get('/')->to(
controller => 'Controller::Index',
action => 'index');
action => 'index'
);
## registration page
$r->get('/register')->to(
controller => 'Controller::Register',
action => 'register');
action => 'register'
);
$r->post('/register')->to(
controller => 'Controller::Register',
action => 'register_do');
action => 'register_do'
);
## login page
$r->get('/login')->to(
controller => 'Controller::Login',
action => 'login');
action => 'login'
);
$r->post('/login')->to(
controller => 'Controller::Login',
action => 'login_do');
action => 'login_do'
);
## logout
$r->get('/logout')->to(
controller => 'Controller::Logout',
action => 'logout_do')
action => 'logout_do'
)
}
1;
@ -103,17 +115,17 @@ __END__
CharmBoard - revive the fun posting experience!
=head1 NOTES
This documentation is intended for prospective code
contributors. If you're looking to set CharmBoard up,
look for the Markdown format (.md) documentation instead.
This documentation is intended for prospective code contributors. If
you're looking to set CharmBoard up, look for the Markdown format
(.md) documentation instead.
CharmBoard uses a max line length of 60 chars and a tab
size of two spaces.
CharmBoard uses a max line length of 70 chars and a tab size of two
spaces.
=head1 DESCRIPTION
CharmBoard is forum software written in Perl with
Mojolicious, intended to be a more fun alternative to the
bigger forum suites available today, inspired by older
forum software like AcmlmBoard, while also being more
modernized in terms of security practices than they are.
CharmBoard is forum software written in Perl with Mojolicious,
intended to be a more fun alternative to the bigger forum suites
available today, inspired by older forum software like AcmlmBoard,
while also being more modernized in terms of security practices than
they are.
=cut

View File

@ -12,34 +12,35 @@ sub index {
my $self = shift;
# fetch a list of all categories
my @all_cat =
$self->schema->resultset('Categories')->fetch_all;
my @all_cat = $self->schema->resultset('Categories')->fetch_all;
# create a Tree::Simple object that will contain the list
# of categories and the subforums that belong to them
my $tree =
Tree::Simple->new("subf_list", Tree::Simple->ROOT);
my $tree = Tree::Simple->new("subf_list", Tree::Simple->ROOT);
my (@fetch_subf, $cat_branch);
foreach my $iter_cat (@all_cat) {
# create branch of subf_list for the current category
$cat_branch =
Tree::Simple->new($iter_cat, $tree);
# create branch of subf_list for the current category
$cat_branch = Tree::Simple->new($iter_cat, $tree);
# fetch all subforums that belong to this category
@fetch_subf =
$self->schema->resultset('Subforums')
$self->schema->resultset('Subforums')
->fetch_by_cat($iter_cat);
# add each fetched subforum as children of the branch
# for the current category
foreach my $iter_subf (@fetch_subf) {
Tree::Simple->new($iter_subf, $cat_branch)}}
Tree::Simple->new($iter_subf, $cat_branch)
}
}
$self->render(
template => 'index',
category_tree => $tree)}
template => 'index',
category_tree => $tree
)
}
1;
__END__

View File

@ -15,10 +15,12 @@ sub login {
$self->render(
template => 'login',
error => $self->flash('error'),
message => $self->flash('message'))};
message => $self->flash('message')
)
}
sub login_do {
my $self = shift;
my $self = shift;
my $username = $self->param('username');
my $password = $self->pepper . ':' . $self->param('password');
@ -28,24 +30,26 @@ sub login_do {
# 'our' so they work throughout the entire subroutine
our ($user_info, $pass_check, $user_id, $session_key);
try { # check user credentials first
# check user credentials first
try {
# check to see if user by entered username exists
$user_info = $self->schema->resultset('Users')->search(
{username => $username});
$user_info = $self->schema->resultset('Users')
->search({ username => $username });
$user_info or die;
# now check password validity
$pass_check = passchk($user_info->get_column('salt')->first,
$user_info->get_column('password')->first, $password);
$pass_check or die;}
$pass_check or die;
catch ($catch_error) { # redirect to login page on fail
} catch ($catch_error) { # redirect to login page on fail
print $catch_error;
$self->flash(error => 'Username or password incorrect.');
$self->redirect_to('login');}
$self->redirect_to('login');
}
try { # now attempt to create session
# get user ID for session creation
try { # now attempt to create session
# get user ID for session creation
$user_id = $user_info->get_column('user_id')->first;
# gen session key
@ -57,23 +61,29 @@ sub login_do {
user_id => $user_id,
session_expiry => time + 604800,
is_ip_bound => 0,
bound_ip => undef }) or die;
bound_ip => undef
})
or die;
# now create session cookie for user
$self->session(is_auth => 1);
$self->session(user_id => $user_id);
$self->session(is_auth => 1 );
$self->session(user_id => $user_id );
$self->session(session_key => $session_key);
$self->session(expiration => 604800);
$self->session(expiration => 604800 );
# redirect to index upon success
$self->redirect_to('/')}
$self->redirect_to('/')
catch ($catch_error) { # redirect to login page on fail
} catch ($catch_error) { # redirect to login page on fail
print $catch_error;
$self->flash(error => 'Your username and password were correct,
but a server error prevented you from logging in. This has been
logged so the administrator can fix it.');
$self->redirect_to('login')}}
$self->flash(
error => 'Your username and password were correct, but a server
error prevented you from logging in. This has been logged
so the administrator can fix it.'
);
$self->redirect_to('login')
}
}
1;

View File

@ -11,11 +11,16 @@ sub logout_do {
my $self = shift;
# destroy entry for this session in the database
$self->schema->resultset('Session')->search({
session_key => $self->session('session_key')})->delete;
$self->schema->resultset('Session')
->search({ session_key => $self->session('session_key') })
->delete;
# now nuke the actual session cookie
$self->session(expires => 1);
# redirect to index
$self->redirect_to('/')}
$self->redirect_to('/')
}
1;
__END__

View File

@ -14,7 +14,9 @@ sub register {
$self->render(
template => 'register',
error => $self->flash('error'),
message => $self->flash('message'))};
message => $self->flash('message')
)
}
# process submitted registration form
sub register_do {
@ -31,38 +33,41 @@ sub register_do {
# 'our' so they work throughout the entire subroutine
our ($userCheck, $emailCheck, $salt, $hash);
try { # make sure registration info is valid
# make sure registration info is valid
try {
# TODO: implement email validation here at some point
# check to make sure all required fields are filled
($username, $email, $password, $confirmPassword)
or die "Please fill out all required fields.";
or die "Please fill out all required fields.";
# check to make sure both passwords match
# TODO: add check on frontend for this for people with JS enabled
$password eq $confirmPassword
or die "Passwords do not match";
or die "Passwords do not match";
# check to make sure username and/or email isn't already in use;
# if not, continue with registration
## search for input username and email in database
$userCheck = $self->schema->resultset('Users')->search(
{username => $username})->single;
$emailCheck = $self->schema->resultset('Users')->search(
{email => $email})->single;
$userCheck = $self->schema->resultset('Users')
->search({ username => $username })->single;
$emailCheck = $self->schema->resultset('Users')
->search({ email => $email })->single;
($userCheck && $emailCheck) eq undef
or die "Username already in use.\nemail already in use.";
or die "Username already in use.\nemail already in use.";
($userCheck) eq undef
or die "Username already in use.";
or die "Username already in use.";
($emailCheck) eq undef
or die "email already in use."}
catch ($catchError) {
or die "email already in use."
} catch ($catchError) {
$self->flash(error => $catchError);
$self->redirect_to('register');}
$self->redirect_to('register')
}
try {
$password = $self->pepper . ':' . $password;
# return hashed result + salt
($salt, $hash) = passgen($password) or die;
@ -72,15 +77,22 @@ sub register_do {
email => $email,
password => $hash,
salt => $salt,
signup_date => time }) or die;
signup_date => time
})
or die;
$self->flash(message => 'User registered successfully!');
$self->redirect_to('register')}
catch ($catchError) {
$self->redirect_to('register')
} catch ($catchError) {
print $catchError;
$self->flash(error => 'Your registration info was correct, but a
server error prevented you from registering. This has been
logged so the administrator can fix it.');
$self->redirect_to('register')}}
$self->flash(
error =>
'Your registration info was correct, but a server error
prevented you from registering. This has been logged so the
administrator can fix it.'
);
$self->redirect_to('register')
}
}
1;

View File

@ -12,21 +12,23 @@ sub subf_view {
my $subf_id = $self->param('id');
my $subf_cat =
$self->schema->resultset('Subforums')->cat_from_id($subf_id);
$self->schema->resultset('Subforums')->cat_from_id($subf_id);
my $cat_title =
$self->schema->resultset('Categories')
$self->schema->resultset('Categories')
->title_from_id($subf_cat);
my @thread_list =
$self->schema->resultset('Threads')->fetch_by_subf($subf_id);
$self->schema->resultset('Threads')->fetch_by_subf($subf_id);
$self->render(
template => 'subf',
subf_id => $subf_id,
cat_title => $cat_title,
subf_title =>
$self->schema->resultset('Subforums')
subf_title => $self->schema->resultset('Subforums')
->title_from_id($subf_id),
thread_list => \@thread_list)}
thread_list => \@thread_list
)
}
1;
__END__

View File

@ -12,26 +12,30 @@ use Exporter qw(import);
our @EXPORT = qw(passgen passchk);
sub passgen {
my $argon2 = Authen::Passphrase::Argon2->new(
my $_argon2 = Authen::Passphrase::Argon2->new(
salt => seasoning(32),
passphrase => $_[0],
cost => 17,
factor => '32M',
parallelism => 1,
size => 32 );
size => 32
);
return ($argon2->salt_hex, $argon2->hash_hex)};
return ($_argon2->salt_hex, $_argon2->hash_hex)
}
sub passchk {
my $argon2 = Authen::Passphrase::Argon2->new(
my $_argon2 = Authen::Passphrase::Argon2->new(
salt_hex => $_[0],
hash_hex => $_[1],
cost => 17,
factor => '32M',
parallelism => 1,
size => 32 );
size => 32
);
return ($argon2->match($_[2]))}
return ($_argon2->match($_[2]))
}
1;

View File

@ -11,16 +11,20 @@ use Exporter qw(import);
our @EXPORT = qw(seasoning);
sub seasoning {
my @spices = qw(0 1 2 3 4 5 6 7 8 9 a b c d e f g
h i j k l m n o p q r s t u v w x y z A B C D E F
G H I J K L M N O P Q R S T U V W X Y Z ! @ $ % ^
& * / ? . ; : \ [ ] - _ < > ` ~ + = £ ¥ ¢ §);
my @_spices = qw(0 1 2 3 4 5 6 7 8 9 a b c d e f g
h i j k l m n o p q r s t u v w x y z A B C D E F
G H I J K L M N O P Q R S T U V W X Y Z ! @ $ % ^
& * / ? . ; : \ [ ] - _ < > ` ~ + = £ ¥ ¢ §);
my $blend;
while (length($blend) < $_[0]) {
# gen num to choose char for $blend
$blend = $blend . $spices[irand(@spices)]};
my $_blend;
while (length($_blend) < $_[0]) {
return ($blend); }
# gen num to choose char for $blend
$_blend = $_blend . $_spices[ irand(@_spices) ]
}
return ($_blend)
}
1;
__END__

View File

@ -8,8 +8,9 @@ use experimental qw(try smartmatch);
use base qw(DBIx::Class::Schema);
__PACKAGE__->load_namespaces(
result_namespace => 'Source',
resultset_namespace => 'Set');
result_namespace => 'Source',
resultset_namespace => 'Set'
);
1;

View File

@ -10,26 +10,26 @@ use base 'DBIx::Class::ResultSet';
sub fetch_all {
my $_set = shift;
my $_fetch =
$_set->search({},
{order_by => 'cat_rank'});
my $_fetch = $_set->search({}, { order_by => 'cat_rank' });
return($_fetch->get_column('cat_id')->all)}
return ($_fetch->get_column('cat_id')->all)
}
sub title_from_id {
my $_set = shift;
return(
$_set->search({'cat_id' => $_[0]})->
get_column('cat_name')->first)}
return (
$_set->search({ 'cat_id' => $_[0] })->get_column('cat_name')
->first)
}
1;
__END__
=pod
=head1 NAME
CharmBoard::Schema::Set::Categories - DBIC ResultSet for
the categories table
CharmBoard::Schema::Set::Categories - DBIC ResultSet for the
categories table
=head1 SYNOPSIS
=head1 DESCRIPTION

View File

@ -10,26 +10,26 @@ use base 'DBIx::Class::ResultSet';
sub fetch_by_cat {
my $_set = shift;
my $_fetch =
$_set->search(
{'subf_cat' => $_[0] },
{order_by => 'subf_rank'});
my $_fetch = $_set->search({ 'subf_cat' => $_[0] },
{ order_by => 'subf_rank' });
return($_fetch->get_column('subf_id')->all)}
return ($_fetch->get_column('subf_id')->all)
}
sub cat_from_id {
my $_set = shift;
return(
$_set->search({'subf_id' => $_[0]})->
get_column('subf_cat')->first)}
return (
$_set->search({ 'subf_id' => $_[0] })->get_column('subf_cat')
->first)
}
sub title_from_id {
my $_set = shift;
return(
$_set->search({'subf_id' => $_[0]})->
get_column('subf_name')->first)}
return ($_set->search({ 'subf_id' => $_[0] })
->get_column('subf_name')->first)
}
1;
__END__

View File

@ -11,16 +11,17 @@ sub fetch_by_subf {
my $_set = shift;
my $_fetch =
$_set->search({'thread_subf' => $_[0]});
$_set->search({ 'thread_subf' => $_[0] });
return($_fetch->get_column('thread_id')->all)}
return ($_fetch->get_column('thread_id')->all)
}
sub title_from_id {
my $_set = shift;
return(
$_set->search({'thread_id' => $_[0]})->
get_column('thread_title')->first)}
return ($_set->search({ 'thread_id' => $_[0] })
->get_column('thread_title')->first)
}
1;
__END__

View File

@ -9,16 +9,20 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('categories');
__PACKAGE__->add_columns(
cat_id => {
data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0, },
cat_rank => {
data_type => 'integer',
is_nullable => 0, },
cat_name => {
data_type => 'text',
is_nullable => 0, });
cat_id =>
{ data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0,
},
cat_rank =>
{ data_type => 'integer',
is_nullable => 0,
},
cat_name =>
{ data_type => 'text',
is_nullable => 0,
}
);
__PACKAGE__->set_primary_key('cat_id');

View File

@ -9,32 +9,37 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('posts');
__PACKAGE__->add_columns(
post_id => {
data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0, },
user_id => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0, },
thread_id => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0, },
post_date => {
data_type => 'integer',
is_nullable => 0, });
post_id =>
{ data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0,
},
user_id =>
{ data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0,
},
thread_id =>
{ data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0,
},
post_date =>
{ data_type => 'integer',
is_nullable => 0,
}
);
__PACKAGE__->set_primary_key('post_id');
__PACKAGE__->belongs_to(
user_id =>
'CharmBoard::Schema::Source::Users',
'user_id' );
user_id => 'CharmBoard::Schema::Source::Users',
'user_id'
);
__PACKAGE__->belongs_to(
thread_id =>
'CharmBoard::Schema::Source::Threads',
'thread_id' );
thread_id => 'CharmBoard::Schema::Source::Threads',
'thread_id'
);
1;
__END__

View File

@ -9,28 +9,34 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('sessions');
__PACKAGE__->add_columns(
session_key => {
data_type => 'text',
is_nullable => 0, },
user_id => {
data_type => 'integer',
is_nullable => 0, },
session_expiry => {
data_type => 'numeric',
is_nullable => 0, },
is_ip_bound => {
data_type => 'integer',
is_nullable => 0, },
bound_ip => {
data_type => 'text',
is_nullable => 1, });
session_key =>
{ data_type => 'text',
is_nullable => 0,
},
user_id =>
{ data_type => 'integer',
is_nullable => 0,
},
session_expiry =>
{ data_type => 'numeric',
is_nullable => 0,
},
is_ip_bound =>
{ data_type => 'integer',
is_nullable => 0,
},
bound_ip =>
{ data_type => 'text',
is_nullable => 1,
}
);
__PACKAGE__->set_primary_key('session_key');
__PACKAGE__->belongs_to(
user_id =>
'CharmBoard::Schema::Source::Users',
'user_id');
user_id => 'CharmBoard::Schema::Source::Users',
'user_id'
);
1;
__END__

View File

@ -9,31 +9,37 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('subforums');
__PACKAGE__->add_columns(
subf_id => {
data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0, },
subf_cat => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0, },
subf_rank => {
data_type => 'integer',
is_numeric => 1,
is_nullable => 0, },
subf_name => {
data_type => 'text',
is_nullable => 0, },
subf_desc => {
data_type => 'text',
is_nullable => 1, });
subf_id =>
{ data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0,
},
subf_cat =>
{ data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0,
},
subf_rank =>
{ data_type => 'integer',
is_numeric => 1,
is_nullable => 0,
},
subf_name =>
{ data_type => 'text',
is_nullable => 0,
},
subf_desc =>
{ data_type => 'text',
is_nullable => 1,
}
);
__PACKAGE__->set_primary_key('subf_id');
__PACKAGE__->belongs_to(
subf_cat =>
'CharmBoard::Schema::Source::Categories',
{'foreign.cat_id' => 'self.subf_cat'});
subf_cat => 'CharmBoard::Schema::Source::Categories',
{ 'foreign.cat_id' => 'self.subf_cat' }
);
1;
__END__

View File

@ -9,31 +9,37 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('threads');
__PACKAGE__->add_columns(
thread_id => {
data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0, },
thread_title => {
data_type => 'text',
is_nullable => 0, },
thread_op => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0, },
thread_subf => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0, });
thread_id =>
{ data_type => 'integer',
is_auto_increment => 1,
is_nullable => 0,
},
thread_title =>
{ data_type => 'text',
is_nullable => 0,
},
thread_op =>
{ data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0,
},
thread_subf =>
{ data_type => 'integer',
is_foreign_key => 1,
is_nullable => 0,
}
);
__PACKAGE__->set_primary_key('thread_id');
__PACKAGE__->belongs_to(
thread_subf =>
'CharmBoard::Schema::Source::Subforums',
{'foreign.subf_id' => 'self.thread_subf'});
thread_subf => 'CharmBoard::Schema::Source::Subforums',
{ 'foreign.subf_id' => 'self.thread_subf' }
);
__PACKAGE__->belongs_to(
thread_op => 'CharmBoard::Schema::Source::Posts',
{'foreign.post_id' => 'self.thread_op'});
thread_op => 'CharmBoard::Schema::Source::Posts',
{ 'foreign.post_id' => 'self.thread_op' }
);
1;
__END__

View File

@ -9,27 +9,34 @@ use base qw(DBIx::Class::Core);
__PACKAGE__->table('users');
__PACKAGE__->add_columns(
user_id => {
data_type => 'integer',
is_numeric => 1,
is_nullable => 0,
is_auto_increment => 1, },
username => {
data_type => 'text',
is_nullable => 0, },
email => {
data_type => 'text',
is_nullable => 0, },
password => {
data_type => 'text',
is_nullable => 0, },
salt => {
data_type => 'text',
is_nullable => 0, },
signup_date => {
data_type => 'integer',
is_numeric => 1,
is_nullable => 0, });
user_id =>
{ data_type => 'integer',
is_numeric => 1,
is_nullable => 0,
is_auto_increment => 1,
},
username =>
{ data_type => 'text',
is_nullable => 0,
},
email =>
{ data_type => 'text',
is_nullable => 0,
},
password =>
{ data_type => 'text',
is_nullable => 0,
},
salt =>
{ data_type => 'text',
is_nullable => 0,
},
signup_date =>
{ data_type => 'integer',
is_numeric => 1,
is_nullable => 0,
}
);
__PACKAGE__->set_primary_key('user_id');

View File

@ -13,5 +13,3 @@ use Mojolicious::Commands;
Mojolicious::Commands->start_app('CharmBoard');
__END__