mourningdove/cgi-bin/DW/Controller/Settings.pm

631 lines
21 KiB
Perl
Raw Normal View History

2026-05-24 01:03:05 +00:00
#!/usr/bin/perl
#
# This code is based on code originally created by the LiveJournal project
# owned and operated by Live Journal, Inc. The code has been modified and expanded
# by Dreamwidth Studios, LLC. These files were originally licensed under
# the terms of the license supplied by Live Journal, Inc, which can
# currently be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.
#
#
# Authors:
# Afuna <coder.dw@afunamatata.com>
# Mark Smith <mark@dreamwidth.org>
# Jen Griffin <kareila@livejournal.com> (lostinfo conversion)
#
# Copyright (c) 2014-2020 by Dreamwidth Studios, LLC.
package DW::Controller::Settings;
use strict;
use v5.10;
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);
use Imager::QRCode;
use DW::Auth::TOTP;
use DW::Controller;
use DW::Routing;
use DW::Template;
use DW::FormErrors;
use DW::Captcha;
=head1 NAME
DW::Controller::Settings - Controller for settings/settings-related pages
=cut
DW::Routing->register_string( "/accountstatus", \&account_status_handler, app => 1 );
DW::Routing->register_string( "/changepassword", \&changepassword_handler, app => 1, );
DW::Routing->register_string( "/lostinfo", \&lostinfo_handler, app => 1, );
DW::Routing->register_string( "/manage2fa", \&manage2fa_handler, app => 1, );
DW::Routing->register_string( "/manage2fa/qrcode", \&manage2fa_qrcode_handler, format => 'png' );
sub account_status_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 1, authas => { showall => 1 } );
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $u = $rv->{u};
my $get = $r->get_args;
my $ml_scope = "/settings/accountstatus.tt";
my @statusvis_options =
$u->is_suspended
? ( 'S' => LJ::Lang::ml("$ml_scope.journalstatus.select.suspended") )
: (
'V' => LJ::Lang::ml("$ml_scope.journalstatus.select.activated"),
'D' => LJ::Lang::ml("$ml_scope.journalstatus.select.deleted"),
);
my %statusvis_map = @statusvis_options;
my $errors = DW::FormErrors->new;
# TODO: this feels like a misuse of DW::FormErrors. Make a new class?
my $messages = DW::FormErrors->new;
my $warnings = DW::FormErrors->new;
my $post;
if ( $r->did_post && LJ::check_referer('/accountstatus') ) {
$post = $r->post_args;
my $new_statusvis = $post->{statusvis};
# are they suspended?
$errors->add( "", ".error.nochange.suspend" )
if $u->is_suspended;
# are they expunged?
$errors->add( "", '.error.nochange.expunged' )
if $u->is_expunged;
# invalid statusvis
$errors->add( "", '.error.invalid' )
unless $new_statusvis eq 'D' || $new_statusvis eq 'V';
my $did_change = $u->statusvis ne $new_statusvis;
# no need to change?
$messages->add(
"",
$u->is_community ? '.message.nochange.comm' : '.message.nochange',
{ statusvis => $statusvis_map{$new_statusvis} }
) unless $did_change;
if ( !$errors->exist && $did_change ) {
my $res = 0;
my $ip = $r->get_remote_ip;
my @date = localtime(time);
my $date = sprintf(
"%02d:%02d %02d/%02d/%04d",
@date[ 2, 1 ],
$date[3],
$date[4] + 1,
$date[5] + 1900
);
if ( $new_statusvis eq 'D' ) {
$res = $u->set_deleted;
$u->set_prop( delete_reason => $post->{reason} || "" );
if ($res) {
# sending ESN status was changed
LJ::Event::SecurityAttributeChanged->new(
$u,
{
action => 'account_deleted',
ip => $ip,
datetime => $date,
}
)->fire;
}
}
elsif ( $new_statusvis eq 'V' ) {
## Restore previous statusvis of journal. It may be different
## from 'V', it may be read-only, or locked, or whatever.
my @previous_status =
grep { $_ ne 'D' } $u->get_previous_statusvis;
my $new_status = $previous_status[0] || 'V';
my $method = {
V => 'set_visible',
L => 'set_locked',
M => 'set_memorial',
O => 'set_readonly',
R => 'set_renamed',
}->{$new_status};
$errors->add_string( "", "Can't set status '" . LJ::ehtml($new_status) . "'" )
unless $method;
unless ( $errors->exist ) {
$res = $u->$method;
$u->set_prop( delete_reason => "" );
if ($res) {
LJ::Event::SecurityAttributeChanged->new(
$u,
{
action => 'account_activated',
ip => $ip,
datetime => $date,
}
)->fire;
$did_change = 1;
}
}
}
# error updating?
$errors->add( "", ".error.db" ) unless $res;
unless ( $errors->exist ) {
$messages->add(
"",
$u->is_community
? '.message.success.comm'
: '.message.success',
{ statusvis => $statusvis_map{$new_statusvis} }
);
if ( $new_statusvis eq 'D' ) {
$messages->add(
"",
$u->is_community
? ".message.deleted.comm"
: ".message.deleted2",
{ sitenameshort => $LJ::SITENAMESHORT }
);
# are they leaving any community admin-less?
if ( $u->is_person ) {
my $cids = LJ::load_rel_target( $remote, "A" );
my @warn_comm_ids;
if ($cids) {
# verify there are visible maintainers for each community
foreach my $cid (@$cids) {
push @warn_comm_ids, $cid
unless grep { $_->is_visible }
values
%{ LJ::load_userids( @{ LJ::load_rel_user( $cid, 'A' ) } ) };
}
# and if not, warn them about it
if (@warn_comm_ids) {
my $commlist = '<ul>';
$commlist .= '<li>' . $_->ljuser_display . '</li>'
foreach values %{ LJ::load_userids(@warn_comm_ids) };
$commlist .= '</ul>';
$warnings->add(
"",
'.message.noothermaintainer',
{
commlist => $commlist,
manage_url => LJ::create_url("/communities/list"),
pagetitle => LJ::Lang::ml('/communities/list.tt.title'),
}
);
}
}
}
}
}
}
}
my $vars = {
form_url => LJ::create_url( undef, keep_args => ['authas'] ),
extra_delete_text => LJ::Hooks::run_hook( "accountstatus_delete_text", $u ),
statusvis_options => \@statusvis_options,
u => $u,
delete_reason => $u->prop('delete_reason'),
errors => $errors,
messages => $messages,
warnings => $warnings,
formdata => $post,
authas_form => $rv->{authas_form},
};
return DW::Template->render_template( 'settings/accountstatus.tt', $vars );
}
sub manage2fa_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $post_args = $r->post_args;
my $errors = DW::FormErrors->new;
if ( DW::Auth::TOTP->is_enabled($remote) ) {
my $vars;
if ( $post_args->{'action:show-codes'} ) {
$vars->{codes} = [ DW::Auth::TOTP->get_recovery_codes($remote) ];
$vars->{show_codes} = 1;
}
elsif ( $post_args->{'action:disable'} ) {
return DW::Template->render_template('settings/manage2fa/disable.tt');
}
elsif ( $post_args->{'action:disable-confirm'} ) {
if ( !$remote->check_password( $post_args->{password} ) ) {
$errors->add_string( password => 'Password invalid.' );
return DW::Template->render_template( 'settings/manage2fa/disable.tt',
{ errors => $errors } );
}
else {
DW::Auth::TOTP->disable( $remote, $post_args->{password} );
return DW::Template->render_template( 'settings/manage2fa/index-disabled.tt',
{ just_disabled => 1 } );
}
}
return DW::Template->render_template( 'settings/manage2fa/index-enabled.tt', $vars );
}
# User does not have 2fa
if ( $post_args->{'action:setup'} ) {
return DW::Template->render_template( 'settings/manage2fa/setup.tt',
{ totp_secret => DW::Auth::TOTP->generate_secret } );
}
elsif ( $post_args->{'action:enable'} ) {
my $secret = $post_args->{totp_secret};
my $verify_code = $post_args->{verification_code};
if ( !DW::Auth::TOTP->check_code( $remote, $verify_code, secret => $secret ) ) {
$errors->add_string(
verification_code => 'Verification code failed. Please, try again.' );
return DW::Template->render_template( 'settings/manage2fa/setup.tt',
{ totp_secret => $secret, errors => $errors } );
}
DW::Auth::TOTP->enable( $remote, $secret );
return DW::Template->render_template( 'settings/manage2fa/index-enabled.tt',
{ codes => [ DW::Auth::TOTP->get_recovery_codes($remote) ], show_codes => 1 } );
}
return DW::Template->render_template('settings/manage2fa/index-disabled.tt');
}
sub manage2fa_qrcode_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller();
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $secret = $r->get_args->{'secret'} or die;
my $qrcode = Imager::QRCode->new( casesensitive => 1, );
my $image = $qrcode->plot(
qq{otpauth://totp/Dreamwidth:%20$remote->{user}?secret=$secret&issuer=Dreamwidth});
my $data;
$image->write( data => \$data, type => 'png' );
$r->print($data);
return $r->OK;
}
sub changepassword_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 1, anonymous => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $get = $r->get_args;
my $post;
my $remote = $rv->{remote};
my ( $aa, $authu );
my $ml_scope = "/settings/changepassword.tt";
if ( my $auth = $get->{auth} ) {
my $lostinfo_url = LJ::create_url("/lostinfo");
return error_ml("$ml_scope.error.invalidarg")
unless $auth =~ /^(\d+)\.(.+)$/;
$aa = LJ::is_valid_authaction( $1, $2 );
return error_ml("$ml_scope.error.invalidarg")
unless $aa;
return error_ml( "$ml_scope.error.actionalreadyperformed", { url => $lostinfo_url } )
if $aa->{used} eq 'Y';
return $r->redirect($lostinfo_url)
unless $aa->{action} eq 'reset_password';
# confirmed the identity...
$authu = LJ::load_userid( $aa->{userid} );
# verify the email can still receive passwords
return error_ml( "$ml_scope.error.emailchanged", { url => $lostinfo_url } )
unless $authu->can_receive_password( $aa->{arg1} );
}
return error_ml("$ml_scope.error.identity")
if $remote && $remote->is_identity;
my $errors = DW::FormErrors->new;
if ( $r->did_post && $r->post_args->{mode} eq 'submit' ) {
$post = $r->post_args;
my $user = $authu ? $authu->user : LJ::canonical_username( $post->{user} );
my $password = $post->{password};
my $newpass1 = LJ::trim( $post->{newpass1} );
my $newpass2 = LJ::trim( $post->{newpass2} );
my $u = LJ::load_user($user);
$errors->add( "user", ".error.invaliduser" ) unless $u;
$errors->add( "user", ".error.identity" ) if $u && $u->is_identity;
$errors->add( "user", ".error.changetestaccount" )
if grep { $user eq $_ } @LJ::TESTACCTS;
unless ( $errors->exist ) {
if ( LJ::login_ip_banned($u) ) {
$errors->add( "user", "error.ipbanned" );
}
elsif (!$authu
&& !$u->check_password($password) )
{
$errors->add( "password", ".error.badoldpassword" );
LJ::handle_bad_login($u);
}
}
if ( !$newpass1 ) {
$errors->add( "newpass1", ".error.blankpassword" );
}
elsif ( $newpass1 ne $newpass2 ) {
$errors->add( "newpass2", ".error.badnewpassword" );
}
else {
my $checkpass = LJ::CreatePage->verify_password(
password => $newpass1,
u => $u
);
$errors->add( "newpass1", ".error.badcheck", { error => $checkpass } )
if $checkpass;
}
# don't allow changes if email address is not validated,
# unless they got the reset email
$errors->add( "newpass1", ".error.notvalidated" )
if $u->{status} ne 'A' && !$authu;
# now let's change the password
unless ( $errors->exist ) {
$u->infohistory_add( 'password', 'changed' );
$u->log_event( 'password_change', { remote => $remote } );
$u->set_password( $post->{newpass1} );
# if we used an authcode, we'll need to expire it now
LJ::mark_authaction_used($aa) if $authu;
# Kill all sessions, forcing user to relogin
$u->kill_all_sessions;
LJ::send_mail(
{
'to' => $u->email_raw,
'from' => $LJ::ADMIN_EMAIL,
'fromname' => $LJ::SITENAME,
'charset' => 'utf-8',
'subject' => LJ::Lang::ml("$ml_scope.email.subject"),
'body' => LJ::Lang::ml(
"$ml_scope.email.body2",
{
sitename => $LJ::SITENAME,
siteroot => $LJ::SITEROOT,
username => $u->{user},
}
),
}
);
my $success_ml =
$remote
? "settings/changepassword.tt.withremote"
: "settings/changepassword.tt";
return DW::Controller->render_success(
$success_ml,
{
url => LJ::create_url("/login"),
}
);
LJ::Hooks::run_hook( 'user_login', $u );
}
}
my $vars = {
needs_validation => !$authu
&& $remote
&& !$r->did_post
&& $remote->{status} ne 'A',
authu => $authu,
remote => $remote,
formdata => $post || { user => $remote ? $remote->user : "" },
errors => $errors,
};
return DW::Template->render_template( 'settings/changepassword.tt', $vars );
}
sub lostinfo_handler {
my ( $ok, $rv ) = controller( form_auth => 1, anonymous => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $form_args = $r->post_args;
my $captcha = DW::Captcha->new( 'lostinfo', %{$form_args} );
my $vars = { captcha => $captcha };
return DW::Template->render_template( 'settings/lostinfo.tt', $vars )
unless $r->did_post;
my $scope = "/settings/lostinfo.tt";
my $captcha_error;
return error_ml( "$scope.error.captcha", { errmsg => $captcha_error } )
unless $captcha->validate( err_ref => \$captcha_error );
my $ip = $r->get_remote_ip;
if ( $form_args->{lostpass} ) {
# this template doesn't exist but the strings do
$scope = "/settings/lostpass.tt";
my $email = LJ::trim( $form_args->{email_p} );
my $u = LJ::load_user( $form_args->{user} );
return error_ml("error.username_notfound") unless $u;
return error_ml("$scope.error.syndicated") if $u->is_syndicated;
return error_ml("$scope.error.commnopassword") if $u->is_community;
return error_ml("$scope.error.purged") if $u->is_expunged;
return error_ml("$scope.error.renamed") if $u->is_renamed;
return error_ml("$scope.error.toofrequent") unless $u->rate_log( "lostinfo", 1 );
# Check to see if they are banned from sending a password
if ( LJ::sysban_check( 'lostpassword', $u->user ) ) {
LJ::Sysban::note(
$u->id,
"Password retrieval blocked based on user",
{ user => $u->user }
);
return error_ml("$scope.error.sysbanned");
}
# Check to see if this email address can receive password reminders
$email ||= $u->email_raw;
return error_ml("$scope.error.unconfirmed")
unless $u->can_receive_password($email);
return error_ml("$scope.error.invalidemail")
if $LJ::BLOCKED_PASSWORD_EMAIL && $email =~ /$LJ::BLOCKED_PASSWORD_EMAIL/;
# email address is okay, build email body
my $aa = LJ::register_authaction( $u->id, "reset_password", $email );
my $body = LJ::Lang::ml(
"$scope.lostpasswordmail.reset",
{
lostinfolink => "$LJ::SITEROOT/lostinfo",
sitename => $LJ::SITENAME,
username => $u->user,
emailadr => $u->email_raw,
resetlink => "$LJ::SITEROOT/changepassword?auth=$aa->{aaid}.$aa->{authcode}",
}
);
$body .= "\n\n";
$body .= LJ::Lang::ml( "$scope.lostpasswordmail.ps", { remoteip => $ip } );
$body .= "\n\n";
LJ::send_mail(
{
to => $email,
from => $LJ::ADMIN_EMAIL,
fromname => $LJ::SITENAME,
charset => 'utf-8',
subject => LJ::Lang::ml("$scope.lostpasswordmail.subject"),
body => $body,
}
) or die "Error: couldn't send email";
return DW::Controller->render_success('settings/lostpass.tt');
}
if ( $form_args->{lostuser} ) {
# this template doesn't exist but the strings do
$scope = "/settings/lostuser.tt";
my $email = LJ::trim( $form_args->{email_u} );
return error_ml("$scope.error.no_email") unless $email;
my @users;
foreach my $uid ( LJ::User->accounts_by_email($email) ) {
my $u = LJ::load_userid($uid);
next if !$u || $u->is_expunged; # not purged
# As the idea is to limit spam to one e-mail address,
# if any of their usernames are over the limit, then
# don't send them any more e-mail.
return error_ml("$scope.error.toofrequent") unless $u->rate_log( "lostinfo", 1 );
push @users, $u->display_name;
}
return error_ml( "$scope.error.no_usernames_for_email",
{ address => LJ::ehtml($email) || 'none' } )
unless @users;
# we have valid usernames, build email body
my $userlist = join "\n ", @users;
my $body = LJ::Lang::ml(
"$scope.email.body",
{
sitename => $LJ::SITENAME,
emailaddress => $email,
usernames => $userlist,
remoteip => $ip,
siteurl => $LJ::SITEROOT,
}
);
LJ::send_mail(
{
to => $email,
from => $LJ::ADMIN_EMAIL,
fromname => $LJ::SITENAME,
charset => 'utf-8',
subject => LJ::Lang::ml("$scope.email.subject"),
body => $body,
}
) or die "Error: couldn't send email";
return DW::Controller->render_success('settings/lostuser.tt');
}
# have post, but no lostuser or lostpass?
return error_ml("error.nobutton");
}
1;