# Joiner - a user registration module for Oddmuse
#
# Copyright (C) 2014 Aki Goto <tyatsumi@gmail.com>
#
# Based on Login Module for Oddmuse (login.pl)
# Copyright (C) 2004  Fletcher T. Penney <fletcher@freeshell.org>
#
# Codes included from questionasker.pl for Oddmuse
# Copyright (C) 2004  Brock Wilcox <awwaiid@thelackthereof.org>
# Copyright (C) 2006, 2007  Alex Schroeder <alex@gnu.org>
#
# Codes included from ReCaptcha Extension for Oddmuse
# Copyleft  2008             by B.w.Curry <http://www.raiazome.com>.
# Copyright 2004, 2008       by Brock Wilcox <awwaiid@thelackthereof.org>.
# Copyright 2006, 2007, 2008 by Alex Schroeder <alex@gnu.org>.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

use strict;
use v5.10;

AddModuleDescription('joiner.pl', 'Joiner Extension');

our ($q, $Now, %Action, @MyAdminCode, @MyInitVariables, $UserGotoBar, $DataDir, $FullUrl, $SiteName, %CookieParameters, @QuestionaskerQuestions, $QuestionaskerRememberAnswer, $QuestionaskerSecretKey, $ReCaptchaSecretKey, $ReCaptchaRememberAnswer);

=head1 DESCRIPTION

This is a user registration module for Oddmuse based on Fletcher's login.pl.
File locking and some functions are improved.

=head1 MENUS

When not logged in, 'Login' and 'Register' menus are shown on UserGotoBar.
When logged in, 'Logout' and 'Account Settings' menus are shown on UserGotoBar.
In 'Account Settings', you can change password and email address.
'Forgot Password?' menu is in 'Login' menu.

In Administration menu, 'Account Management' menu is shown.
In this menu, you can ban accounts.

=head1 REGISTRATION

To register account, use 'Register' menu.
You have to confirm the email address entered by visiting the link
on the confirmation email sent to the address.

=head1 CONFIGURATION

You can set configuration variables below.

$JoinerSalt:
To increase security for storing passwords, specify arbitrary string.
Default = ''.

$JoinerGeneratorSalt:
To increase security for auto generated passwords and ticket keys,
specify arbitrary string.
Default = ''.

$JoinerEmailSenderAddress
The sender address of the emails sent by this module.
Default = 'www-data@example.net'.

$JoinerCommentAllowed
If 0, you must loggin to write comments.
Default = 1.

$JoinerMinimumPasswordLength
Default = 6.

$JoinerWait
Retrying the email-sending commands is restricted to certain frequency.
Specify the waiting duration for retry in seconds.
Default = 60 * 10.

$JoinerQuestionModule
Specify cooperative anti-spam extension.
Supported values are 'Questionasker', 'ReCaptcha' and 'GdSecurityImage'.
Corresponding anti-spam extension need to be installed separately.
Default = (auto detect).

$JoinerDataDir
When using with Namespaces Extension, specify original root data directory
to concentrate Joiner data files in it.
Default = $DataDir.

$JoinerEmailCommand
The command used to send email.
Default = '/usr/sbin/sendmail -oi -t'.

$JoinerEmailRegExp
Email address format.
Default = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$'.

=head1 DATA STRUCTURE

Account data is stored in $DataDir/joiner directory.
Registration pending email data is stored in $DataDir/joiner_email directory.
Their data format is same as wiki page's.

=cut

our ($JoinerSalt, $JoinerGeneratorSalt, $JoinerEmailSenderAddress,
  $JoinerCommentAllowed, $JoinerMinimumPasswordLength, $JoinerWait,
  $JoinerQuestionModule, $JoinerDataDir, $JoinerEmailCommand, $JoinerEmailRegExp);
our ($JoinerDir, $JoinerEmailDir, $JoinerMessage, $JoinerLoggedIn);

use Digest::MD5;

$Action{joiner_register} = \&JoinerDoRegister;
$Action{joiner_process_registration} = \&JoinerDoProcessRegistration;
$Action{joiner_confirm_registration} = \&JoinerDoConfirmRegistration;
$Action{joiner_login} = \&JoinerDoLogin;
$Action{joiner_process_login} = \&JoinerDoProcessLogin;
$Action{joiner_ticket} = \&JoinerDoProcessLogin;
$Action{joiner_logout} = \&JoinerDoLogout;
$Action{joiner_account_settings} = \&JoinerDoAccountSettings;
$Action{joiner_change_password} = \&JoinerDoChangePassword;
$Action{joiner_process_change_password} = \&JoinerDoProcessChangePassword;
$Action{joiner_forgot_password} = \&JoinerDoForgotPassword;
$Action{joiner_process_forgot_password} = \&JoinerDoProcessForgotPassword;
$Action{joiner_recover} = \&JoinerDoProcessLogin;
$Action{joiner_change_email} = \&JoinerDoChangeEmail;
$Action{joiner_process_change_email} = \&JoinerDoProcessChangeEmail;
$Action{joiner_confirm_email} = \&JoinerDoConfirmEmail;
$Action{joiner_manage} = \&JoinerDoManage;
$Action{joiner_ban} = \&JoinerDoBan;
$Action{joiner_process_ban} = \&JoinerDoProcessBan;

push(@MyAdminCode, \&JoinerAdminCode);
push(@MyInitVariables, \&JoinerInitVariables);

sub JoinerGetPasswordHash {
  my ($raw_password) = @_;
  return Digest::MD5::md5_hex($JoinerSalt . $raw_password);
}

sub JoinerRequestLockOrError {
  my ($name) = @_;
  # 10 tries, 3 second wait, die on error
  return RequestLockDir($name, 10, 3, 1);
}

sub JoinerGetEmailFile {
  my ($email) = @_;
  return "$JoinerEmailDir/$email.email";
}

sub JoinerGetAccountFile {
  my ($username) = @_;
  return "$JoinerDir/$username.account";
}

# Always call JoinerCreateAccount within a lock.
sub JoinerCreateAccount {
  my ($username, $password, $email, $key) = @_;

  my ($account_status, $account_data)
    = ReadFile(JoinerGetAccountFile($username));
  if ($account_status) {
    return T('Username:') . ' ' .
      Ts('The username %s already exists.', $username);
  }

  my ($email_status, $email_data) = ReadFile(JoinerGetEmailFile($email));
  if ($email_status) {
    my $email_page = ParseData($email_data);
    if ($email_page->{confirmed}) {
      return Ts('The email address %s has already been used.', $email);
    }
    if ($email_page->{registration_time} + $JoinerWait > $Now) {
      my $min = 1 + int(($email_page->{registration_time} + $JoinerWait - $Now) / 60);
      return Ts('Wait %s minutes before try again.', $min);
    }
  }
  my %email_page = ();
  $email_page{username} = $username;
  $email_page{email} = $email;
  $email_page{confirmed} = 0;
  $email_page{registration_time} = $Now;
  CreateDir($JoinerEmailDir);
  WriteStringToFile(JoinerGetEmailFile($email), EncodePage(%email_page));

  my %page;
  $page{username} = $username;
  $page{password} = $password;
  $page{email} = $email;
  $page{key} = $key;
  $page{confirmed} = 0;
  $page{registration_time} = $Now;
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%page));
  return '';
}

sub JoinerSendRegistrationConfirmationEmail {
  my ($email, $username, $key) = @_;

  my $link = "$FullUrl?action=joiner_confirm_registration&joiner_username=" . UrlEncode($username) . "&joiner_key=$key";

  open (my $EMAIL, '|', $JoinerEmailCommand);
  print $EMAIL "To: $email\n";
  print $EMAIL "From: $JoinerEmailSenderAddress\n";
  print $EMAIL "Subject: $SiteName " . T('Registration Confirmation') . "\n";
  print $EMAIL "\n";
  print $EMAIL T('Visit the link below to confirm registration.') . "\n";
  print $EMAIL "\n";
  print $EMAIL "$link\n";
  print $EMAIL "\n";
  close $EMAIL;
}

sub JoinerSendRecoverAccountEmail {
  my ($email, $username, $key) = @_;

  my $link = "$FullUrl?action=joiner_recover&joiner_username=" . UrlEncode($username) . "&joiner_key=$key";

  open (my $EMAIL, '|', $JoinerEmailCommand);
  print $EMAIL "To: $email\n";
  print $EMAIL "From: $JoinerEmailSenderAddress\n";
  print $EMAIL "Subject: " . T('Recover Account') . " - $SiteName\n";
  print $EMAIL "\n";
  print $EMAIL T('You can login by following the link below. Then set new password.') . "\n";
  print $EMAIL "\n";
  print $EMAIL "$link\n";
  print $EMAIL "\n";
  close $EMAIL;
}

sub JoinerSendChangeEmailEmail {
  my ($email, $username, $key) = @_;

  my $link = "$FullUrl?action=joiner_confirm_email&joiner_username=" . UrlEncode($username) . "&joiner_key=$key";

  open (my $EMAIL, '|', $JoinerEmailCommand);
  print $EMAIL "To: $email\n";
  print $EMAIL "From: $JoinerEmailSenderAddress\n";
  print $EMAIL "Subject: " . T('Change Email Address') . " - $SiteName\n";
  print $EMAIL "\n";
  print $EMAIL T('To confirm changing email address, follow the link below.') . "\n";
  print $EMAIL "\n";
  print $EMAIL "$link\n";
  print $EMAIL "\n";
  close $EMAIL;
}

sub JoinerQuestionaskerGetQuestion {
  my $need_button = shift;
  my $button = $need_button ? $q->submit(-value=>T('Go!')) : '';
  my $question_number = int(rand(scalar(@QuestionaskerQuestions)));
  return $q->div({-class=>'question'},
                 $q->p(T('To submit this form you must answer this question:')),
                 $q->blockquote($q->p($QuestionaskerQuestions[$question_number][0]),
                                $q->p($q->input({-type=>'text', -name=>'answer'}),
                                      $q->input({-type=>'hidden', -name=>'question_num',
                                                 -value=>$question_number}),
                                      $button)));
}

sub JoinerQuestionaskerCheck {
  my $question_num = GetParam('question_num', undef);
  my $answer = GetParam('answer', undef);
  unless (UserIsEditor()
          or $QuestionaskerRememberAnswer && GetParam($QuestionaskerSecretKey, 0)
          or $QuestionaskerQuestions[$question_num][1]($answer)) {
    # logging to the error log file of the server
    # warn "Q: '$QuestionaskerQuestions[$question_num][0]', A: '$answer'\n";
    return 0;
  }
  # Set the secret key only if a question has in fact been answered
  if (not GetParam($QuestionaskerSecretKey, 0)
      and $QuestionaskerQuestions[$question_num][1]($answer)) {
    SetParam($QuestionaskerSecretKey, 1)
  }
  return 1;
}

sub JoinerReCaptchaCheck {
  my $correct = 0;

  unless (UserIsEditor() or UserIsAdmin()
    or $ReCaptchaRememberAnswer && GetParam($ReCaptchaSecretKey, 0)
    or $correct = ReCaptchaCheckAnswer() # remember this!
    ) {
    # logging to the error log file of the server
    # warn "Q: '$ReCaptchaQuestions[$question_num][0]', A: '$answer'\n";
    return 0;
  }

  if (not GetParam($ReCaptchaSecretKey, 0) and $correct) {
    SetParam($ReCaptchaSecretKey, 1);
  }
  return 1;
}

sub JoinerGetQuestion {
  if ($JoinerQuestionModule eq 'Questionasker') {
    if (not $QuestionaskerRememberAnswer && GetParam($QuestionaskerSecretKey, 0)
        and not UserIsEditor()) {
      return JoinerQuestionaskerGetQuestion();
    }
  } elsif ($JoinerQuestionModule eq 'ReCaptcha') {
    if (not $ReCaptchaRememberAnswer && GetParam($ReCaptchaSecretKey, 0)
        and not UserIsEditor()) {
      return ReCaptchaGetQuestion();
    }
  } elsif ($JoinerQuestionModule eq 'GdSecurityImage') {
    return GdSecurityImageGetHtml();
  }
  return '';
}

sub JoinerCheckQuestion {
  if ($JoinerQuestionModule eq 'Questionasker') {
    if (!JoinerQuestionaskerCheck()) {
      $JoinerMessage = T('Question:') . ' ' . T('You did not answer correctly.');
      return 0;
    }
  } elsif ($JoinerQuestionModule eq 'ReCaptcha') {
    if (!JoinerReCaptchaCheck()) {
      $JoinerMessage = T('CAPTCHA:') . ' ' . T('You did not answer correctly.');
      return 0;
    }
  } elsif ($JoinerQuestionModule eq 'GdSecurityImage') {
    if (!GdSecurityImageCheck()) {
      $JoinerMessage = T('CAPTCHA:') . ' ' . T('Please type the six characters from the anti-spam image');
      return 0;
    }
  }
  return 1;
}

sub JoinerDoRegister {
  print GetHeader('', T('Registration'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print $q->start_p();
  print T('The username must be valid page name.');
  print $q->end_p();
  print $q->start_p();
  print T('Confirmation email will be sent to the email address.');
  print $q->end_p();

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_registration'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($q->textfield(-name=>'joiner_username', -id=>'joiner_username')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_password'}, T('Password:'))),
    $q->td($q->password_field(-name=>'joiner_password', -id=>'joiner_password')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_repeated_password'}, T('Repeat Password:'))),
    $q->td($q->password_field(-name=>'joiner_repeated_password', -id=>'joiner_repeated_password')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_email'}, T('Email:'))),
    $q->td($q->textfield(-name=>'joiner_email', -id=>'joiner_email')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Submit', -value=>T('Submit'))));
  print $q->table($table);
  print JoinerGetQuestion();
  print $q->end_form;

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessRegistration {
  my $username = GetParam('joiner_username', '');
  my $password = GetParam('joiner_password', '');
  my $repeated_password = GetParam('joiner_repeated_password', '');
  my $email = GetParam('joiner_email', '');
  my $message;

  $message = ValidId($username);
  if ($message ne '') {
    $JoinerMessage = T('Username:') . ' ' . $message;
    JoinerDoRegister();
    return;
  }

  if (!($email =~ /$JoinerEmailRegExp/)) {
    $JoinerMessage = T('Email:') . ' ' . T('Bad email address format.');
    JoinerDoRegister();
    return;
  }

  if (length($password) < $JoinerMinimumPasswordLength) {
    $JoinerMessage = T('Password:') . ' ' . Ts('Password needs to have at least %s characters.', $JoinerMinimumPasswordLength);
    JoinerDoRegister();
    return;
  }
  if ($repeated_password ne $password) {
    $JoinerMessage = T('Password:') . ' ' . T('Repeat Password:') . ' ' . T('Passwords differ.');
    JoinerDoRegister();
    return;
  }

  if (!JoinerCheckQuestion()) {
    JoinerDoRegister();
    return;
  }

  my $hash = JoinerGetPasswordHash($password);
  my $key = Digest::MD5::md5_hex($JoinerGeneratorSalt . rand());
  JoinerRequestLockOrError('joiner');
  $message = JoinerCreateAccount($username, $hash, $email, $key);
  ReleaseLockDir('joiner');
  if ($message ne '') {
    $JoinerMessage = $message;
    JoinerDoRegister();
    return;
  }

  JoinerSendRegistrationConfirmationEmail($email, $username, $key);

  print GetHeader('', T('Email Sent'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Ts('Confirmation email has been sent to %s. Visit the link on the mail to confirm registration.', $email);
  print $q->end_p();

  print $q->end_div();
  PrintFooter();
}

sub JoinerShowRegistrationConfirmationFailed {
  print GetHeader('', T('Failed to Confirm Registration'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoConfirmRegistration {
  my $username = GetParam('joiner_username', '');
  my $key = GetParam('joiner_key', '');

  my $message = ValidId($username);
  if ($message ne '') {
    $JoinerMessage = T('Username:') . ' ' . $message;
    JoinerShowRegistrationConfirmationFailed();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Invalid key.');
    JoinerShowRegistrationConfirmationFailed();
    return;
  }
  my $page = ParseData($data);

  if ($key ne $page->{key}) {
    $JoinerMessage = T('Invalid key.');
    JoinerShowRegistrationConfirmationFailed();
    return;
  }

  if ($page->{registration_time} + $JoinerWait < $Now) {
    $JoinerMessage = T('The key expired.');
    JoinerShowRegistrationConfirmationFailed();
    return;
  }

  $page->{key} = '';
  $page->{confirmed} = 1;
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  my $email = $page->{email};
  JoinerRequestLockOrError('joiner');
  my ($email_status, $email_data) = ReadFile(JoinerGetEmailFile($email));
  ReleaseLockDir('joiner');
  if ($email_status) {
    my $email_page = ParseData($email_data);
    $email_page->{confirmed} = 1;
    JoinerRequestLockOrError('joiner');
    CreateDir($JoinerEmailDir);
    WriteStringToFile(JoinerGetEmailFile($email), EncodePage(%$email_page));
    ReleaseLockDir('joiner');
  }

  print GetHeader('', T('Registration Confirmed'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print T('Now, you can login by using username and password.');
  print $q->end_p();

  print $q->start_p();
  print ScriptLink('action=joiner_login', T('Login'));
  print $q->end_p();

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoLogin {
  print GetHeader('', T('Login'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_login'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($q->textfield(-name=>'joiner_username', -id=>'joiner_username')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_password'}, T('Password:'))),
    $q->td($q->password_field(-name=>'joiner_password', -id=>'joiner_password')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Submit', -value=>T('Submit'))));
  print $q->table($table);
  print JoinerGetQuestion();
  print $q->end_form;

  print $q->start_p();
  print ScriptLink('action=joiner_forgot_password', T('Forgot your password?'));
  print $q->end_p();

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessLogin {
  my $username = GetParam('joiner_username', '');
  my $password = GetParam('joiner_password', '');
  my $key = GetParam('joiner_key', '');

  my $message = ValidId($username);
  if ($message ne '') {
    $JoinerMessage = T('Username:') . ' ' . $message;
    JoinerDoLogin();
    return;
  }

  if (!($key ne '' && $password eq '') && !JoinerCheckQuestion()) {
    JoinerDoLogin();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Login failed.');
    JoinerDoLogin();
    return;
  }
  my $page = ParseData($data);
  my $hash = JoinerGetPasswordHash($password);
  if ($hash eq $page->{password}) {
    $page->{recover} = 0;
    SetParam('joiner_recover', 0);
  } elsif ($key ne '' && $key eq $page->{recover_key}) {
    if ($page->{recover_time} + $JoinerWait < $Now) {
      $JoinerMessage = T('The key expired.');
      JoinerDoLogin();
      return;
    }
    $page->{recover} = 1;
    SetParam('joiner_recover', 1);
  } else {
    $JoinerMessage = T('Login failed.');
    JoinerDoLogin();
    return;
  }
  if ($page->{banned}) {
    $JoinerMessage = T('You are banned.');
    JoinerDoLogin();
    return;
  }

  if (!$page->{confirmed}) {
    $JoinerMessage = T('You must confirm email address.');
    JoinerDoLogin();
    return;
  }

  my $session = Digest::MD5::md5_hex(rand());
  $page->{session} = $session;
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  SetParam('username', $username);
  SetParam('joiner_session', $session);

  print GetHeader('', T('Logged in'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Ts('%s has logged in.', $username);
  print $q->end_p();

  if ($page->{recover}) {
    print $q->start_p();
    print T('You should set new password immediately.');
    print $q->end_p();

    print $q->start_p();
    print ScriptLink('action=joiner_change_password', T('Change Password'));
    print $q->end_p();
  }

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoLogout {
  my $username = GetParam('username', '');

  SetParam('username', '');
  SetParam('joiner_session', '');

  print GetHeader('', T('Logged out'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Ts('%s has logged out.', $username);
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoAccountSettings {
  if (!JoinerIsLoggedIn()) {
    JoinerDoLogin();
    return;
  }

  my $username = GetParam('username', '');

  print GetHeader('', T('Account Settings'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print T('Username:') . ' ' . $username;
  print $q->end_p();

  print $q->start_p();
  print ScriptLink('action=joiner_logout', T('Logout'));
  print $q->end_p();

  print $q->start_p();
  print ScriptLink('action=joiner_change_password', T('Change Password'));
  print $q->end_p();

  print $q->start_p();
  print ScriptLink('action=joiner_change_email', T('Change Email Address'));
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoChangePassword {
  if (!JoinerIsLoggedIn()) {
    JoinerDoLogin();
    return;
  }

  my $username = GetParam('username', '');
  my $recover = GetParam('joiner_recover', '');

  print GetHeader('', T('Change Password'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_change_password'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($username));
  if (!$recover) {
    $table .= $q->Tr($q->td($q->label({-for=>'joiner_current_password'}, T('Current Password:'))),
      $q->td($q->password_field(-name=>'joiner_current_password', -id=>'joiner_current_password')));
  }
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_new_password'}, T('New Password:'))),
    $q->td($q->password_field(-name=>'joiner_new_password', -id=>'joiner_new_password')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_repeat_new_password'}, T('Repeat New Password:'))),
    $q->td($q->password_field(-name=>'joiner_repeat_new_password', -id=>'joiner_repeat_new_password')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Submit', -value=>T('Submit'))));
  print $q->table($table);
  print $q->end_form;

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessChangePassword {
  if (!JoinerIsLoggedIn()) {
    JoinerDoLogin();
    return;
  }

  my $username = GetParam('username', '');
  my $current_password = GetParam('joiner_current_password', '');
  my $new_password = GetParam('joiner_new_password', '');
  my $repeat_new_password = GetParam('joiner_repeat_new_password', '');

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Login failed.');
    JoinerDoChangePassword();
    return;
  }
  my $page = ParseData($data);
  my $hash = JoinerGetPasswordHash($current_password);
  if (!$page->{recover} && $hash ne $page->{password}) {
    $JoinerMessage = T('Current Password:') . ' ' . T('Password is wrong.');
    JoinerDoChangePassword();
    return;
  }

  if (length($new_password) < $JoinerMinimumPasswordLength) {
    $JoinerMessage = T('New Password:') . ' ' . Ts('Password needs to have at least %s characters.', $JoinerMinimumPasswordLength);
    JoinerDoChangePassword();
    return;
  }
  if ($repeat_new_password ne $new_password) {
    $JoinerMessage = T('New Password:') . ' ' . T('Repeat New Password:') . ' ' . T('Passwords differ.');
    JoinerDoChangePassword();
    return;
  }

  $page->{password} = JoinerGetPasswordHash($new_password);
  $page->{key} = '';
  $page->{recover} = '';
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  SetParam('joiner_recover', 0);

  print GetHeader('', T('Password Changed'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print T('Your password has been changed.');
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoForgotPassword {
  print GetHeader('', T('Forgot Password'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print $q->start_p();
  print T('Enter email address, and recovery login ticket will be sent.');
  print $q->end_p();

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_forgot_password'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_email'}, T('Email:'))),
    $q->td($q->textfield(-name=>'joiner_email', -id=>'joiner_email')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Submit', -value=>T('Submit'))));
  print $q->table($table);
  print JoinerGetQuestion();
  print $q->end_form;

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessForgotPassword {
  my $email = GetParam('joiner_email', '');

  if (!($email =~ /$JoinerEmailRegExp/)) {
    $JoinerMessage = T('Email:') . ' ' . T('Bad email address format.');
    JoinerDoForgotPassword();
    return;
  }

  if (!JoinerCheckQuestion()) {
    JoinerDoForgotPassword();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($email_status, $email_data) = ReadFile(JoinerGetEmailFile($email));
  ReleaseLockDir('joiner');
  if (!$email_status) {
    $JoinerMessage = T('Email:') . ' ' . T('Not found.');
    JoinerDoForgotPassword();
    return;
  }
  my $email_page = ParseData($email_data);

  my $username = $email_page->{username};
  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Username:') . ' ' . T('Not found.');
    JoinerDoForgotPassword();
    return;
  }
  my $page = ParseData($data);

  if ($email ne $page->{email}) {
    $JoinerMessage = T('The mail address is not valid anymore.');
    JoinerDoForgotPassword();
    return;
  }

  if ($page->{recover_time} + $JoinerWait > $Now) {
    my $min = 1 + int(($page->{recover_time} + $JoinerWait - $Now) / 60);
    $JoinerMessage = Ts('Wait %s minutes before try again.', $min);
    JoinerDoForgotPassword();
    return;
  }

  my $key = Digest::MD5::md5_hex($JoinerGeneratorSalt . rand());
  $page->{recover_time} = $Now;
  $page->{recover_key} = $key;
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  JoinerSendRecoverAccountEmail($email, $username, $key);

  print GetHeader('', T('Email Sent'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Ts('An email has been sent to %s with further instructions.', $email);
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoChangeEmail {
  if (!JoinerIsLoggedIn()) {
    JoinerDoLogin();
    return;
  }

  my $username = GetParam('username', '');

  print GetHeader('', T('Change Email Address'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_change_email'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($username));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_email'}, T('New Email Address:'))),
    $q->td($q->textfield(-name=>'joiner_email', -id=>'joiner_email')));
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_password'}, T('Password:'))),
    $q->td($q->password_field(-name=>'joiner_password', -id=>'joiner_password')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Submit', -value=>T('Submit'))));
  print $q->table($table);
  print $q->end_form;

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessChangeEmail {
  if (!JoinerIsLoggedIn()) {
    JoinerDoLogin();
    return;
  }

  my $username = GetParam('username', '');
  my $email = GetParam('joiner_email', '');
  my $password = GetParam('joiner_password', '');

  if (!($email =~ /$JoinerEmailRegExp/)) {
    $JoinerMessage = T('Email:') . ' ' . T('Bad email address format.');
    JoinerDoChangeEmail();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($email_status, $email_data) = ReadFile(JoinerGetEmailFile($email));
  ReleaseLockDir('joiner');
  if ($email_status) {
    my $email_page = ParseData($email_data);
    if ($email_page->{confirmed} && $email_page->{username} ne $username) {
      $JoinerMessage = T('Email:') . ' ' .
        Ts('The email address %s has already been used.', $email);
      JoinerDoChangeEmail();
      return;
    }
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Failed to load account.');
    JoinerDoChangeEmail();
    return;
  }
  my $page = ParseData($data);

  if ($page->{change_email_time} + $JoinerWait > $Now) {
    my $min = 1 + int(($page->{change_email_time} + $JoinerWait - $Now) / 60);
    $JoinerMessage = Ts('Wait %s minutes before try again.', $min);
    JoinerDoChangeEmail();
    return;
  }

  my $hash = JoinerGetPasswordHash($password);
  if ($hash ne $page->{password}) {
    $JoinerMessage = T('Password:') . ' ' . T('Password is wrong.');
    JoinerDoChangeEmail();
    return;
  }

  my $key = Digest::MD5::md5_hex(rand());
  $page->{change_email} = $email;
  $page->{change_email_key} = $key;
  $page->{change_email_time} = $Now;
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  JoinerSendChangeEmailEmail($email, $username, $key);

  print GetHeader('', T('Email Sent'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Ts('An email has been sent to %s with a login ticket.', $email);
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerShowConfirmEmailFailed {
  print GetHeader('', T('Confirmation Failed'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print $q->start_p();
  print T('Failed to confirm.');
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoConfirmEmail {
  my $username = GetParam('joiner_username', '');
  my $key = GetParam('joiner_key', '');

  my $message = ValidId($username);
  if ($message ne '') {
    $JoinerMessage = $message;
    JoinerShowConfirmEmailFailed();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Failed to load account.');
    JoinerShowConfirmEmailFailed();
    return;
  }
  my $page = ParseData($data);

  if ($key ne $page->{change_email_key}) {
    $JoinerMessage = T('Invalid key.');
    JoinerShowConfirmEmailFailed();
    return;
  }

  my $new_email = $page->{change_email};
  $page->{email} = $new_email;
  $page->{change_email} = '';
  $page->{change_email_key} = '';
  $page->{change_email_time} = '';
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  my %email_page = ();
  $email_page{username} = $username;
  $email_page{email} = $new_email;
  $email_page{confirmed} = 1;
  $email_page{registration_time} = $Now;
  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerEmailDir);
  WriteStringToFile(JoinerGetEmailFile($new_email), EncodePage(%email_page));
  ReleaseLockDir('joiner');

  print GetHeader('', T('Email Address Changed'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print Tss('Email address for %1 has been changed to %2.', $username, $new_email);
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoManage {
  UserIsAdminOrError();

  print GetHeader('', T('Account Management'), '');
  print $q->start_div({-class=>'joiner'});

  print $q->start_p();
  print ScriptLink('action=joiner_ban', T('Ban Account'));
  print $q->end_p();

  print $q->end_div();
  print PrintFooter();
}

sub JoinerDoBan {
  UserIsAdminOrError();

  print GetHeader('', T('Ban Account'), '');
  print $q->start_div({-class=>'joiner'});

  if ($JoinerMessage) {
    print $q->start_p() . $q->b($JoinerMessage) . $q->end_p();
  }

  print $q->start_p();
  print T('Enter username of the account to ban:');
  print $q->end_p();

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_ban'});
  print $q->input({-type=>'hidden', -name=>'joiner_ban', -value=>'1'});
  my $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($q->textfield(-name=>'joiner_username', -id=>'joiner_username')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Ban', -value=>T('Ban'))));
  print $q->table($table);
  print $q->end_form;

  print $q->start_p();
  print T('Enter username of the account to unban:');
  print $q->end_p();

  print GetFormStart(undef, undef, undef);
  print $q->input({-type=>'hidden', -name=>'action', -value=>'joiner_process_ban'});
  print $q->input({-type=>'hidden', -name=>'joiner_ban', -value=>'0'});
  $table = '';
  $table .= $q->Tr($q->td($q->label({-for=>'joiner_username'}, T('Username:'))),
    $q->td($q->textfield(-name=>'joiner_username', -id=>'joiner_username')));
  $table .= $q->Tr($q->td(), $q->td($q->submit(-name=>'Unban', -value=>T('Unban'))));
  print $q->table($table);
  print $q->end_form;

  print $q->end_div();
  PrintFooter();
}

sub JoinerDoProcessBan {
  UserIsAdminOrError();

  my $username = GetParam('joiner_username', '');
  my $ban = GetParam('joiner_ban', '');

  my $message = ValidId($username);
  if ($message ne '') {
    $JoinerMessage = $message;
    JoinerDoBan();
    return;
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerMessage = T('Failed to load account.');
    JoinerDoBan();
    return;
  }
  my $page = ParseData($data);

  if ($ban) {
    if ($page->{banned}) {
      $JoinerMessage = Ts('%s is already banned.', $username);
      JoinerDoBan();
      return;
    }
    $page->{banned} = 1;
    $page->{session} = '';
    $JoinerMessage = Ts('%s has been banned.', $username);
  } else {
    if (!$page->{banned}) {
      $JoinerMessage = Ts('%s is not banned.', $username);
      JoinerDoBan();
      return;
    }
    $page->{banned} = 0;
    $JoinerMessage = Ts('%s has been unbanned.', $username);
  }

  JoinerRequestLockOrError('joiner');
  CreateDir($JoinerDir);
  WriteStringToFile(JoinerGetAccountFile($username), EncodePage(%$page));
  ReleaseLockDir('joiner');

  JoinerDoBan();
}

sub JoinerIsLoggedIn {
  if ($JoinerLoggedIn ne '') {
    return $JoinerLoggedIn;
  }

  my $username = GetParam('username', '');
  my $session = GetParam('joiner_session', '');

  my $message = ValidId($username);
  if ($message ne '') {
    $JoinerLoggedIn = 0;
    return $JoinerLoggedIn;
  }

  JoinerRequestLockOrError('joiner');
  my ($status, $data) = ReadFile(JoinerGetAccountFile($username));
  ReleaseLockDir('joiner');
  if (!$status) {
    $JoinerLoggedIn = 0;
    return $JoinerLoggedIn;
  }
  my $page = ParseData($data);
  if (!$page->{confirmed}) {
    $JoinerLoggedIn = 0;
    return $JoinerLoggedIn;
  }
  if ($session ne $page->{session}) {
    $JoinerLoggedIn = 0;
    return $JoinerLoggedIn;
  }
  if ($page->{banned}) {
    $JoinerLoggedIn = 0;
    return $JoinerLoggedIn;
  }

  $JoinerLoggedIn = 1;
  return $JoinerLoggedIn;
}

*OldJoinerUserCanEdit = \&UserCanEdit;
*UserCanEdit = \&NewJoinerUserCanEdit;

sub NewJoinerUserCanEdit {
  my ($id, $editing, $comment) = @_;
  if (!OldJoinerUserCanEdit($id, $editing, $comment)) {
    return 0;
  }

  return 1 if UserIsAdmin();
  return 1 if UserIsEditor();
  return 1 if $JoinerCommentAllowed and ($comment or (GetParam('aftertext', '') and not GetParam('text', '')));

  return JoinerIsLoggedIn();
}

*OldJoinerGetHeader = \&GetHeader;
*GetHeader = \&NewJoinerGetHeader;

sub NewJoinerGetHeader {
  if (JoinerIsLoggedIn()) {
    $UserGotoBar = ScriptLink('action=joiner_logout', T('Logout')) . ' ' .
      ScriptLink('action=joiner_account_settings', T('Account Settings')) . ' ' .
      $UserGotoBar;
  } else {
    $UserGotoBar = ScriptLink('action=joiner_login', T('Login')) . ' ' .
      ScriptLink('action=joiner_register', T('Register')) . ' ' .
      $UserGotoBar;
  }

  my ($id, $title, $oldId, $nocache, $status) = @_;
  return OldJoinerGetHeader($id, $title, $oldId, $nocache, $status);
}

sub JoinerAdminCode {
  my ($id, $menuref, $restref) = @_;
  push(@$menuref, ScriptLink('action=joiner_manage', T('Account Management')));
}

sub JoinerInitVariables {
  $JoinerSalt = '' unless defined $JoinerSalt;
  $JoinerGeneratorSalt = '' unless defined $JoinerGeneratorSalt;
  $JoinerEmailSenderAddress = 'www-data@example.net' unless defined $JoinerEmailSenderAddress;
  $JoinerCommentAllowed = 1 unless defined $JoinerCommentAllowed;
  $JoinerMinimumPasswordLength = 6 unless defined $JoinerMinimumPasswordLength;
  $JoinerWait = 60 * 10 unless defined $JoinerWait;
  if (!defined($JoinerQuestionModule)) {
    if (defined &QuestionaskerInit) {
      $JoinerQuestionModule = 'Questionasker';
    } elsif (defined &ReCaptchaInit) {
      $JoinerQuestionModule = 'ReCaptcha';
    } elsif (defined &GdSecurityImageInitVariables) {
      $JoinerQuestionModule = 'GdSecurityImage';
    } else {
      $JoinerQuestionModule = '';
    }
  }
  $JoinerDataDir = $DataDir unless defined $JoinerDataDir;
  $JoinerEmailCommand = '/usr/sbin/sendmail -oi -t' unless defined $JoinerEmailCommand;
  $JoinerEmailRegExp = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$' unless defined $JoinerEmailRegExp;

  $JoinerDir = "$JoinerDataDir/joiner";
  $JoinerEmailDir = "$JoinerDataDir/joiner_email";
  $JoinerLoggedIn = '';

  $CookieParameters{'joiner_session'} = '';
  $CookieParameters{'joiner_recover'} = '';
}
