Compare commits

..

2 Commits

Author SHA1 Message Date
ngoomie cce6e230c9 Change VSCode specific settings 2023-05-09 00:47:20 -06:00
ngoomie dc9a42f0e6 Add logout, POD documentation, various organizational things, et al
a bunch of POD documentation was addded to various .pm files

`CharmBoard::Controller::Auth` has been split into
`CharmBoard::Controller::Register`, `CharmBoard::Controller::Login`,
and `CharmBoard::Controller:Logout` (that last one is new, too!)

`Main.pm` has been renamed to `Index.pm` like it was in the beginning

the config value `board_name` now works

`CharmBoard::Schema` namespaces were changed (`ResultSource` is now
`Source`, `ResultSet` is now `Set`)

templates were mildly reorganized

almost switched from EPL to HAML. decided I really shouldn't because I
utterly despise anything whitespace sensitive, so it doesn't matter how
compact HAML is in comparison!
2023-05-09 00:37:07 -06:00
26 changed files with 320 additions and 207 deletions

3
.gitignore vendored
View File

@ -1,9 +1,6 @@
# CharmBoard-specific
charmboard.conf
# Mojolicious examples to be nuked later
t/basic.t
# SQLite
*.db
*.db-*

10
.vscode/settings.json vendored
View File

@ -10,7 +10,6 @@
"Facepunch",
"passchk",
"passgen",
"pgsql",
"resultset",
"signup",
"subf",
@ -38,15 +37,6 @@
"bold": false,
"italic": false
},
{
"tag": "//",
"color": "#474747",
"strikethrough": true,
"underline": false,
"backgroundColor": "transparent",
"bold": false,
"italic": false
},
{
"tag": "todo",
"color": "#FF8C00",

View File

@ -1,5 +1,5 @@
{
board_name => '', # this doesn't do anything yet
board_name => '',
database => {
type => '', # 'sqlite' or 'mysql'

View File

@ -1,67 +1,105 @@
package CharmBoard;
use utf8;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious', -signatures;
use CharmBoard::Schema;
=pod
=head1 NAME
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.
CharmBoard uses a max line length of 60 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.
Customization ability is another important goal next to
making software that feels fun for the end user to use.
=cut
# this method will run once at server start
sub startup ($app) {
sub startup ($self) {
# load plugins that require no additional conf
$app->plugin('TagHelpers');
$self->plugin('TagHelpers');
# load configuration from config file
my $config = $app->plugin('Config' => {file => 'charmboard.conf'});
my $config = $self->plugin('Config' =>
{file => 'charmboard.conf'});
# set this specific forum's name
$self->helper(boardName => sub {$config->{board_name}});
# load dev env only stuff, if applicable
if ( $config->{environment} eq 'dev' ) {
$app->plugin('Renderer::WithoutCache');
$app->renderer->cache->max_keys(0)};
$self->plugin('Renderer::WithoutCache');
$self->renderer->cache->max_keys(0)};
# import Mojolicious secrets
$app->secrets($config->{secrets});
$self->secrets($config->{secrets});
# import password pepper value
$app->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, $dbUnicode);
if ($app->config->{database}->{type} ~~ 'sqlite') {
if ($self->config->{database}->{type} ~~ 'sqlite') {
$dsn = "dbi:SQLite:" . $config->{database}->{name};
$dbUnicode = "sqlite_unicode"}
elsif ($app->config->{database}->{type} ~~ 'mysql') {
elsif ($self->config->{database}->{type} ~~ 'mysql') {
$dsn = "dbi:mysql:" . $config->{database}->{name};
$dbUnicode = "mysql_enable_utf"}
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
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', 'mysql'"};
my $schema = CharmBoard::Schema->connect(
$dsn,
$config->{database}->{user},
$config->{database}->{pass},
{$dbUnicode => 1});
$app->helper(schema => sub {$schema});
$self->helper(schema => sub {$schema});
# router
my $r = $app->routes;
my $r = $self->routes;
# controller routes
## index page
$r->get('/')->to(
controller => 'Controller::Main',
controller => 'Controller::Index',
action => 'index');
## registration page
$r->get('/register')->to(
controller => 'Controller::Auth',
controller => 'Controller::Register',
action => 'register');
$r->post('/register')->to(
controller => 'Controller::Auth',
controller => 'Controller::Register',
action => 'register_do');
## login page
$r->get('/login')->to(
controller => 'Controller::Auth',
controller => 'Controller::Login',
action => 'login');
$r->post('/login')->to(
controller => 'Controller::Auth',
controller => 'Controller::Login',
action => 'login_do');
## logout
$r->get('/logout')->to(
controller => 'Controller::Logout',
action => 'logout_do')
}
1;

View File

@ -1,139 +0,0 @@
package CharmBoard::Controller::Auth;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious::Controller', -signatures;
use CharmBoard::Crypt::Password;
use CharmBoard::Crypt::Seasoning;
# initial registration page
sub register ($app) {
$app->render(
template => 'register',
error => $app->flash('error'),
message => $app->flash('message'))};
# process submitted registration form
sub register_do ($app) {
my $username = $app->param('username');
my $email = $app->param('email');
my $password = $app->param('password');
my $confirmPassword = $app->param('confirm-password');
my $catchError;
# declare vars used through multiple try/catch blocks with
# 'our' so they work throughout the entire subroutine
our ($userCheck, $emailCheck, $salt, $hash);
try { # make sure registration info is valid
# TODO: implement email validation here at some point
# check to make sure all required fields are filled
($username, $email, $password, $confirmPassword) ne undef
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";
# 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 = $app->schema->resultset('Users')->search({username => $username})->single;
$emailCheck = $app->schema->resultset('Users')->search({email => $email})->single;
($userCheck && $emailCheck) eq undef
or die "Username already in use.\nemail already in use.";
($userCheck) eq undef
or die "Username already in use.";
($emailCheck) eq undef
or die "email already in use."}
catch ($catchError) {
$app->flash(error => $catchError);
$app->redirect_to('register');}
try {
$password = $app->pepper . ':' . $password;
# return hashed result + salt
($salt, $hash) = passgen($password) or die;
# add user info and pw/salt to DB
$app->schema->resultset('Users')->create({
username => $username,
email => $email,
password => $hash,
salt => $salt,
signup_date => time }) or die;
$app->flash(message => 'User registered successfully!');
$app->redirect_to('register')}
catch ($catchError) {
print $catchError;
$app->flash(error => 'Your registration info was correct,
but a server error prevented you from registering.
This has been logged so your administrator can fix it.');
$app->redirect_to('register')}};
sub login ($app) {
$app->render(
template => 'login',
error => $app->flash('error'),
message => $app->flash('message'))};
sub login_do ($app) {
my $username = $app->param('username');
my $password = $app->pepper . ':' . $app->param('password');
my $catchError;
# declare vars used through multiple try/catch blocks with
# 'our' so they work throughout the entire subroutine
our ($userInfo, $passCheck, $userID, $sessionKey);
try { # check user credentials first
# check to see if user by entered username exists
$userInfo = $app->schema->resultset('Users')->search({username => $username});
$userInfo or die;
# now check password validity
$passCheck = passchk($userInfo->get_column('salt')->first,
$userInfo->get_column('password')->first, $password);
$passCheck or die;}
catch ($catchError) { # redirect to login page on fail
print $catchError;
$app->flash(error => 'Username or password incorrect.');
$app->redirect_to('login');}
try { # now attempt to create session
# get user ID for session creation
$userID = $userInfo->get_column('user_id')->first;
# gen session key
$sessionKey = seasoning(16);
# add session to database
$app->schema->resultset('Session')->create({
session_key => $sessionKey,
user_id => $userID,
session_expiry => time + 604800,
is_ip_bound => 0,
bound_ip => undef }) or die;
# now create session cookie for user
$app->session(is_auth => 1);
$app->session(user_id => $userID);
$app->session(session_key => $sessionKey);
$app->session(expiration => 604800);
# redirect to index upon success
$app->redirect_to('/')}
catch ($catchError) { # redirect to login page on fail
print $catchError;
$app->flash(error => 'Your username and password were correct,
but a server error prevented you from logging in.
This has been logged so your administrator can fix it.');
$app->redirect_to('login')}}
1;

View File

@ -0,0 +1,11 @@
package CharmBoard::Controller::Index;
use utf8;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious::Controller', -signatures;
sub index ($self) {
$self->render(template => 'index')
}
1;

View File

@ -0,0 +1,76 @@
package CharmBoard::Controller::Login;
use utf8;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious::Controller', -signatures;
use CharmBoard::Crypt::Password;
use CharmBoard::Crypt::Seasoning;
=pod
=head1 NAME
CharmBoard::Controller::Login
=cut
sub login ($self) {
$self->render(
template => 'login',
error => $self->flash('error'),
message => $self->flash('message'))};
sub login_do ($self) {
my $username = $self->param('username');
my $password = $self->pepper . ':' . $self->param('password');
my $catchError;
# declare vars used through multiple try/catch blocks with
# 'our' so they work throughout the entire subroutine
our ($userInfo, $passCheck, $userID, $sessionKey);
try { # check user credentials first
# check to see if user by entered username exists
$userInfo = $self->schema->resultset('Users')->search(
{username => $username});
$userInfo or die;
# now check password validity
$passCheck = passchk($userInfo->get_column('salt')->first,
$userInfo->get_column('password')->first, $password);
$passCheck or die;}
catch ($catchError) { # redirect to login page on fail
print $catchError;
$self->flash(error => 'Username or password incorrect.');
$self->redirect_to('login');}
try { # now attempt to create session
# get user ID for session creation
$userID = $userInfo->get_column('user_id')->first;
# gen session key
$sessionKey = seasoning(16);
# add session to database
$self->schema->resultset('Session')->create({
session_key => $sessionKey,
user_id => $userID,
session_expiry => time + 604800,
is_ip_bound => 0,
bound_ip => undef }) or die;
# now create session cookie for user
$self->session(is_auth => 1);
$self->session(user_id => $userID);
$self->session(session_key => $sessionKey);
$self->session(expiration => 604800);
# redirect to index upon success
$self->redirect_to('/')}
catch ($catchError) { # redirect to login page on fail
print $catchError;
$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

@ -0,0 +1,15 @@
package CharmBoard::Controller::Logout;
use utf8;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious::Controller', -signatures;
sub logout_do ($self) {
# destroy entry for this session in the database
$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('/')}
1;

View File

@ -1,7 +0,0 @@
package CharmBoard::Controller::Main;
use Mojo::Base 'Mojolicious::Controller', -signatures;
sub index ($app) {
$app->render(template => 'index')}
1;

View File

@ -0,0 +1,79 @@
package CharmBoard::Controller::Register;
use utf8;
use experimental 'try', 'smartmatch';
use Mojo::Base 'Mojolicious::Controller', -signatures;
use CharmBoard::Crypt::Password;
# initial registration page
sub register ($self) {
$self->render(
template => 'register',
error => $self->flash('error'),
message => $self->flash('message'))};
# process submitted registration form
sub register_do ($self) {
my $username = $self->param('username');
my $email = $self->param('email');
my $password = $self->param('password');
my $confirmPassword = $self->param('confirm-password');
my $catchError;
# declare vars used through multiple try/catch blocks with
# 'our' so they work throughout the entire subroutine
our ($userCheck, $emailCheck, $salt, $hash);
try { # make sure registration info is valid
# 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.";
# 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";
# 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 && $emailCheck) eq undef
or die "Username already in use.\nemail already in use.";
($userCheck) eq undef
or die "Username already in use.";
($emailCheck) eq undef
or die "email already in use."}
catch ($catchError) {
$self->flash(error => $catchError);
$self->redirect_to('register');}
try {
$password = $self->pepper . ':' . $password;
# return hashed result + salt
($salt, $hash) = passgen($password) or die;
# add user info and pw/salt to DB
$self->schema->resultset('Users')->create({
username => $username,
email => $email,
password => $hash,
salt => $salt,
signup_date => time }) or die;
$self->flash(message => 'User registered successfully!');
$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')}}
1;

View File

@ -1,14 +1,37 @@
package CharmBoard::Crypt::Password;
use utf8;
use Authen::Passphrase::Argon2;
use CharmBoard::Crypt::Seasoning;
use Exporter qw(import);
our @EXPORT = qw(passgen passchk);
# subroutine to generate password salt + hashed pw on pass creation
# outputs the salt and then the hashed pw, so when assigning vars
# from this sub's output, do it like this:
# `my ($salt, $hash) = passgen($password);`
=pod
=head1 NAME
CharmBoard::Crypt::Password - password processing module
=head1 SYNOPSIS
=begin perl
use CharmBoard::Crypt::Password;
($salt, $hash) = passgen($plaintextPassword);
$passwordVerification = passchk($salt, $hash, $plaintextPassword)
=end perl
=head1 DESCRIPTION
CharmBoard::Crypt::Password processes passwords, either processing
new passwords for database storage, or checking passwords entered
when logging in to make sure they're correct.
Currently the only available password hashing scheme is Argon2, but
this might be changed later on.
=cut
=pod
=head2 passgen
passgen is the function for generating password salts and hashes to
be inserted into the database. It takes the plaintext password you
wish to hash as the only argument, and outputs the salt and
Argon2 hash string in hexadecimal form.
=cut
sub passgen ($) {
my $argon2 = Authen::Passphrase::Argon2->new(
salt => seasoning(32),
@ -20,9 +43,17 @@ sub passgen ($) {
return ($argon2->salt_hex, $argon2->hash_hex)};
# subroutine to check inputted password against one in DB
# `$_[0]` is the salt, `$_[1]` is the hashed pass, and
# `$_[2]` is the inputted plaintext pepper:password to check
=pod
=head2 passchk
passchk is the function for checking plaintext passwords against the
hashed password + salt already stored in the database. It takes the
salt and Argon2 hash string in hex form plus the plaintext password
as inputs, and outputs a true/false value indicating whether or not
the input password matched. Intended for login authentication or
anywhere else where one may need to verify passwords (i.e. before
changing existing passwords, or for admins confirming they wish to
perform a risky or nonreversible operation.)
=cut
sub passchk ($$$) {
my $argon2 = Authen::Passphrase::Argon2->new(
salt_hex => $_[0],

View File

@ -1,4 +1,5 @@
package CharmBoard::Crypt::Seasoning;
use utf8;
use Math::Random::Secure qw(irand);
use Exporter qw(import);

View File

@ -1,6 +1,8 @@
package CharmBoard::Schema;
use base qw(DBIx::Class::Schema);
__PACKAGE__->load_namespaces();
__PACKAGE__->load_namespaces(
result_namespace => 'Source',
resultset_namespace => 'Set');
1;

View File

@ -1,4 +1,4 @@
package CharmBoard::Schema::Result::Categories;
package CharmBoard::Schema::Source::Categories;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('categories');

View File

@ -1,22 +1,26 @@
package CharmBoard::Schema::Result::Posts;
package CharmBoard::Schema::Source::Posts;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('posts');
__PACKAGE__->add_columns(
post_id => {
data_type => 'integer',
is_foreign_key => 0,
is_auto_increment => 1,
is_nullable => 0, },
user_id => {
data_type => 'integer',
is_foreign_key => 1,
is_auto_increment => 0,
is_nullable => 0, },
thread_id => {
data_type => 'integer',
is_foreign_key => 1,
is_auto_increment => 0,
is_nullable => 0, },
post_date => {
data_type => 'integer',
is_foreign_key => 0,
is_auto_increment => 0,
is_nullable => 0, });
@ -24,11 +28,11 @@ __PACKAGE__->set_primary_key('post_id');
__PACKAGE__->belongs_to(
user_id =>
'CharmBoard::Schema::Result::Users',
'CharmBoard::Schema::Source::Users',
'user_id' );
__PACKAGE__->belongs_to(
thread_id =>
'CharmBoard::Schema::Result::Threads',
'CharmBoard::Schema::Source::Threads',
'thread_id' );
1

View File

@ -1,4 +1,4 @@
package CharmBoard::Schema::Result::Session;
package CharmBoard::Schema::Source::Session;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('sessions');
@ -28,7 +28,7 @@ __PACKAGE__->set_primary_key('session_key');
__PACKAGE__->belongs_to(
user_id =>
'CharmBoard::Schema::Result::Users',
'CharmBoard::Schema::Source::Users',
'user_id');
1

View File

@ -1,4 +1,4 @@
package CharmBoard::Schema::Result::Subforums;
package CharmBoard::Schema::Source::Subforums;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('subforums');
@ -9,6 +9,7 @@ __PACKAGE__->add_columns(
is_nullable => 0, },
subf_cat => {
data_type => 'integer',
is_foreign_key => 1,
is_auto_increment => 0,
is_nullable => 0, },
subf_name => {
@ -24,7 +25,7 @@ __PACKAGE__->set_primary_key('subf_id');
__PACKAGE__->belongs_to(
subf_cat =>
'CharmBoard::Schema::Result::Categories',
'CharmBoard::Schema::Source::Categories',
{'foreign.cat_id' => 'self.subf_cat'});
1

View File

@ -1,4 +1,4 @@
package CharmBoard::Schema::Result::Threads;
package CharmBoard::Schema::Source::Threads;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('threads');
@ -12,13 +12,16 @@ __PACKAGE__->add_columns(
is_nullable => 0, },
thread_subf => {
data_type => 'integer',
is_foreign_key => 1,
is_nullable => 1, });
# ! thread_subf should NOT be nullable once subforums
# ! are properly implemented
__PACKAGE__->set_primary_key('thread_id');
__PACKAGE__->belongs_to(
thread_subf =>
'CharmBoard::Schema::Result::Subforums',
'CharmBoard::Schema::Source::Subforums',
{'foreign.subf_id' => 'self.thread_subf'});
1

View File

@ -1,4 +1,5 @@
package CharmBoard::Schema::Result::Users;
package CharmBoard::Schema::Source::Users;
use utf8;
use base qw(DBIx::Class::Core);
__PACKAGE__->table('users');

View File

@ -1,4 +1,2 @@
% layout 'default', title => 'CharmBoard';
% my $hpm = "you are not logged in";
% if ($self->session('is_auth')) {$hpm = "you're logged in!"};
<%= $hpm %>
% layout 'default', title => $self->boardName;
this is the index page

View File

@ -1,2 +0,0 @@
<a href="/"><h2>CharmBoard</h2></a>
<a href="/login">login</a> | <a href="/register">register</a><br /><br />

View File

@ -5,8 +5,8 @@
<title><%= title %></title>
</head>
<body>
%= include 'layouts/_header'
%= include 'layouts/default/_header'
<%= content %>
%= include 'layouts/_footer'
%= include 'layouts/default/_footer'
</body>
</html>

View File

@ -0,0 +1,14 @@
<%
my $userControls;
# TODO: once implemented, put username + profile link first
if ($self->session('is_auth') == 1) {
my $username =
$userControls = "<a href=\"/logout\">logout</a>"}
else {
$userControls =
"<a href=\"/login\">login</a> |
<a href=\"/register\">register</a>"};
%>
<a href="/"><h2><%== $self->boardName %></h2></a>
<%== $userControls %><br /><br />

View File

@ -1,4 +1,4 @@
% layout 'default', title => 'CharmBoard - Login';
% layout 'default', title => $self->boardName . ' - Login';
% if ($error) {
<p style="color: red"><%= $error %></p>
%};

View File

@ -1,4 +1,4 @@
% layout 'default', title => 'CharmBoard - Registration';
% layout 'default', title => $self->boardName . ' - Registration';
% if ($error) {
<p style="color: red"><%= $error %></p>
%};