#!/usr/bin/perl # # DW::User::Rename - Contains logic to handle account renaming. # # Authors: # Afuna # # Copyright (c) 2010-2014 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. package DW::User::Rename; =head1 NAME DW::User::Rename - Contains logic to handle account renaming. Based on bin/renameuser.pl, from the LiveJournal code =head1 SYNOPSIS use DW::User::Rename; # on a user object my $u = LJ::load_user( "exampleusername" ); if ( $u->can_rename_to( "to_username" ) ) { # print message, whatever... # do rename $u->rename( "to_username", token => $token_object ); # this user object retains old name # but all caches should have been cleared after the rename, so you can get # an updated copy of the user when you do LJ::load_userid $u = LJ::load_userid( $u->userid ); } my $user_a = LJ::load_user( "swap_a" ); my $user_b = LJ::load_user( "swap_b" ); $user_a->swap_usernames( $user_b ) if $user_a->can_rename_to( $user_b->user ); # can also force a rename, which doesn't take into consideration any of the # safeguards. Only call this from an admin page: $u->rename( "to_username", token => $token, force => 1 ) =cut use strict; use warnings; use DW::RenameToken; use DW::FormErrors; =head1 API =head2 C<< $self->can_rename_to( $tousername [, %opts ] ) >> Return true if this user can be renamed to the given username Optional arguments are: =item force => bool, default false =item errors => DW::FormErrors object =item form_from => id of form label used for fromuser (for error association) =cut sub can_rename_to { my ( $self, $tousername, %opts ) = @_; my $errors = $opts{errors} || DW::FormErrors->new; my $formid = $opts{form_from} || 'authas'; unless ($tousername) { $errors->add( 'touser', 'rename.error.noto' ); return 0; } # make sure both from and to are present and, the to is a valid username form $tousername = LJ::canonical_username($tousername); unless ($tousername) { $errors->add( 'touser', 'rename.error.invalidto' ); return 0; } unless ( LJ::isu($self) ) { $errors->add( $formid, 'rename.error.invalidfrom' ); return 0; } # make sure we don't try to rename to ourself if ( $self->user eq $tousername ) { $errors->add( 'touser', 'rename.error.isself' ); return 0; } # force, but only if to and from are valid return 1 if $opts{force}; # can't rename to a reserved username if ( LJ::User->is_protected_username($tousername) ) { $errors->add( 'touser', 'rename.error.reserved', { to => LJ::ehtml($tousername) } ); return 0; } # suspended journals can't be renamed. So can't these other ones. if ( $self->is_suspended || $self->is_readonly || $self->is_locked || $self->is_memorial || $self->is_renamed ) { $errors->add( $formid, 'rename.error.invalidstatusfrom', { from => $self->ljuser_display } ); return 0; } my $check_basics = sub { my ( $fromu, $tou ) = @_; # able to rename to unregistered accounts return { ret => 1 } unless $tou; # some journals can not be renamed to if ( $tou->is_suspended || $tou->is_readonly || $tou->is_locked || $tou->is_memorial || $tou->is_renamed ) { $errors->add( 'touser', 'rename.error.invalidstatusto', { to => $tou->ljuser_display } ); return { ret => 0 }; } # expunged users can always be renamed to return { ret => 1 } if $tou->is_expunged; # only personal journals and communities can be renamed if ( !( $tou->is_personal || $tou->is_community ) ) { $errors->add( 'touser', 'rename.error.invalidjournaltypeto' ); return { ret => 0 }; } }; # only personal and community accounts can be renamed if ( $self->is_personal ) { # able to rename to unregistered accounts my $tou = LJ::load_user($tousername); # check basic stuff that is common for all types of renames my $rv = $check_basics->( $self, $tou ); return $rv->{ret} if $rv; # deleted and visible journals have extra safeguards: # person-to-person return 1 if DW::User::Rename::_are_same_person( $self, $tou ); # person-to-community (only under restricted circumstances for the community) return 1 if DW::User::Rename::_is_authorized_for_comm( $self, $tou ); $errors->add( 'touser', 'rename.error.unauthorized2', { to => $tou->ljuser_display } ); return 0; } elsif ( $self->is_community && LJ::isu( $opts{user} ) ) { my $admin = $opts{user}; # make sure that the community journal is under the admin's control # and satisfies all other conditions that ensure we don't leave members hanging unless ( DW::User::Rename::_is_authorized_for_comm( $admin, $self ) ) { $errors->add( $formid, 'rename.error.unauthorized.forcomm', { comm => $self->ljuser_display } ); return 0; } my $tou = LJ::load_user($tousername); # check basic stuff that is common for all renames my $rv = $check_basics->( $self, $tou ); return $rv->{ret} if $rv; # community-to-person # able to rename to another personal journal under admin's control return 1 if $tou->is_person && DW::User::Rename::_are_same_person( $admin, $tou ); # community-to-community # we checked early on that the admin is authorized to rename this community # so we don't need to check again here return 1 if $tou->is_community; } # be strict in what we accept $errors->add( 'touser', 'rename.error.unknown', { to => LJ::ehtml($tousername) } ); return 0; } =head2 C<< $self->rename( $tousername, token => $rename_token_obj [, %opts] ) >> Rename the given user to the provided username. Requires a user name to rename to, and a token object to store the rename action data. If the username we're returning to is of an existing user then it shall be moved aside to a username of the form "ex_oldusernam123". Returns 1 on success, 0 on failure Optional arguments are: =item force => bool, default false (passed to ->can_rename_to) =item redirect => bool, default false =item errors => DW::FormErrors object =item del_watched_by/del_trusted_by/del_trusted/del_watched/del_communities => bool, default false =item redirect_email => bool, default false (also forced to false if redirect is false) =cut sub rename { my ( $self, $tousername, %opts ) = @_; my $errors = $opts{errors} || DW::FormErrors->new; my $remote = LJ::isu( $opts{user} ) ? $opts{user} : $self; $errors->add( 'token', 'rename.error.tokeninvalid' ) unless $opts{token} && $opts{token}->isa("DW::RenameToken"); $errors->add( 'token', 'rename.error.tokenapplied' ) if $opts{token} && ( $opts{token}->applied || $opts{token}->revoked ); my $can_rename_to = $self->can_rename_to( $tousername, %opts ); return 0 if $errors->exist || !$can_rename_to; $tousername = LJ::canonical_username($tousername); if ( my $tou = LJ::load_user($tousername) ) { return 0 unless DW::User::Rename::_rename_to_ex( $tou, errors => $opts{errors} ); } return DW::User::Rename::_rename( $self, $tousername, %opts ); } =head2 C<< $self->swap_usernames( $touser [, %opts ] ) >> Swap the usernames of these two users. =cut sub swap_usernames { my ( $u1, $u2, %opts ) = @_; my $errors = $opts{errors} || DW::FormErrors->new; my $admin = LJ::isu( $opts{user} ) ? $opts{user} : $u1; my @tokens = @{ $opts{tokens} || [] }; # tokens can be owned by $admin (remote user), # $u1 (authas user), or $u2 (intended target) my $check_uids = { $admin->id => 1, $u1->id => 1, $u2->id => 1 }; foreach my $token (@tokens) { $errors->add( 'token', 'rename.error.tokeninvalid' ) unless $token && $token->isa("DW::RenameToken") && $check_uids->{ $token->ownerid }; $errors->add( 'token', 'rename.error.tokenapplied' ) if $token->applied || $token->revoked; } if ( scalar @tokens >= 2 ) { $errors->add( 'token', 'rename.error.tokenduplicate' ) if ( $tokens[0]->token eq $tokens[1]->token ); } else { $errors->add( 'token', 'rename.error.tokentoofew' ); } my %admin_opts = $u2->is_community ? ( user => $admin ) : (); my $can_rename = $u1->can_rename_to( $u2->username, %opts, %admin_opts ) && $u2->can_rename_to( $u1->username, %opts, %admin_opts ); return 0 if $errors->exist || !$can_rename; my $u1name = $u1->user; my $u2name = $u2->user; my $did_rename = 1; $did_rename &&= DW::User::Rename::_rename_to_ex( $u2, errors => $opts{errors} ); return 0 unless $did_rename; # ugh, but need it to avoid duplicate timestamps in infohistory sleep(1); $did_rename &&= DW::User::Rename::_rename( $u1, $u2name, %opts, %admin_opts, token => $tokens[0] ); return 0 unless $did_rename; $did_rename &&= DW::User::Rename::_rename( $u2, $u1name, %opts, %admin_opts, token => $tokens[1] ); return $did_rename; } =head2 C<< $self->_clear_from_cache >> Internal function to clear a user from various caches. =cut sub _clear_from_cache { my ( $self, $fromusername, $tousername ) = @_; # $fromusername should be the same as $self->user, but we use the passed in value # to be safe, since $self has been renamed at this point. LJ::MemCache::delete("uidof:$fromusername"); LJ::MemCache::delete("uidof:$tousername"); LJ::memcache_kill( $self->userid, "userid" ); delete $LJ::CACHE_USERNAME{ $self->userid }; delete $LJ::REQ_CACHE_USER_NAME{$fromusername}; delete $LJ::REQ_CACHE_USER_ID{ $self->userid }; } =head2 C<< $self->_are_same_person >> Internal function to determine whether two personal accounts are controlled by the same person =cut sub _are_same_person { my ( $p1, $p2 ) = @_; return 0 unless $p1->is_person && $p2->is_person; # able to rename to registered accounts, where both accounts can be identified as the same person # may be able to do this more elegantly once we are able to associate accounts # right now: two valid accounts, same email address, same password, and at least one must be validated return 0 unless $p1->has_same_email_as($p2); return 0 unless $p1->is_validated || $p2->is_validated; return 1; } =head2 C<< $self->_is_authorized_for_comm >> Internal function to determine whether an account can control / manage another account =cut sub _is_authorized_for_comm { my ( $admin, $journal ) = @_; return 0 unless $admin->is_person && $journal->is_community; return 0 unless $admin->can_manage_other($journal); # community must have no users, to avoid confusion my @member_userids = $journal->member_userids; return 0 if scalar @member_userids > 1; return 0 if scalar @member_userids == 1 && $member_userids[0] != $admin->userid; return 1; } =head2 C<< $self->_rename( $tousername, %opts ) >> Internal function to do renames. Low-level, no error-checking on inputs. Only call this when you are sure that all conditions for a rename are satisfied. Returns 1 on success, 0 on failure. =cut sub _rename { my ( $self, $tousername, %opts ) = @_; my $errors = $opts{errors} || DW::FormErrors->new; my $token = $opts{token}; my $fromusername = $self->user; my $dbh = LJ::get_db_writer() or die "Could not get DB handle"; # FIXME: transactions possible? foreach my $table (qw( user useridmap )) { $dbh->do( "UPDATE $table SET user=? WHERE user=?", undef, $tousername, $fromusername ); if ( $dbh->err ) { $errors->add_string( '', $dbh->errstr ); return 0; } } # invalidate DW::User::Rename::_clear_from_cache( $self, $fromusername, $tousername ); # ...existing code... $token->apply( userid => $self->userid, from => $fromusername, to => $tousername ); $self->apply_rename_opts( from => $fromusername, to => $tousername, redirect => { username => $opts{redirect}, email => $opts{redirect_email}, }, del => { map { $_ => $opts{$_} } qw( del_trusted_by del_watched_by del_trusted del_watched del_communities ), }, user => $opts{user}, ); # update current object to new username, and update the email under the new username $self->{user} = $tousername; $self->update_email_alias; # infohistory $self->infohistory_add( "username", $fromusername ); # notification LJ::Event::SecurityAttributeChanged->new( $self, { action => 'account_renamed', ip => eval { BML::get_remote_ip() } || "[unknown]", old_username => $fromusername, } )->fire; return 1; } =head2 C<< $self->apply_rename_opts >> Apply the stated rename options. Will log. Arguments are: =item from, original username (required) =item to, new username (required) =item user, user doing the work if separate from user being renamed, e.g., admin of a community (optional) =item redirect => hashref. (optional) If provided, will handle initial redirect information. If false, will leave as-is. =item del => hashref. (optional) If provided, will delete all relationships of the provided types. =item break_redirect => hashref. (optional) If provided, will break existing redirects redirect/break_redirect hashref: =item username, bool, forward or disconnect username. Default disconnect =item email, bool, forward or disconnect email. Default disconnect del hashref: =item del_trusted_by =item del_watched_by =item del_trusted =item del_watched =item del_communities =cut sub apply_rename_opts { my ( $self, %opts ) = @_; my $from = delete $opts{from}; my $to = delete $opts{to}; my $user = delete $opts{user}; my %extra_args; if ( exists $opts{redirect} && $from && $to ) { if ( exists $opts{redirect}->{username} ) { # break outgoing redirects # we don't want this journal pointing anywhere else, to avoid long chains or possible loops $self->break_redirects; DW::User::Rename->create_redirect_journal( $from, $to ) if $opts{redirect}->{username}; } # this deletes the email under the old username DW::User::Rename->break_email_redirection( $from, $to ) unless $opts{redirect}->{username} && $opts{redirect}->{email}; my @redir; push @redir, "J" if $opts{redirect}->{username}; push @redir, "E" if $opts{redirect}->{username} && $opts{redirect}->{email}; $extra_args{redir} = join( ":", @redir ); } if ( exists $opts{break_redirect} ) { # break incoming redirects if ( $opts{break_redirect}->{username} ) { my $redirect_u = LJ::load_user($from); $redirect_u->break_redirects; $redirect_u->set_statusvis("D"); } DW::User::Rename->break_email_redirection( $from, $to ) if $opts{break_redirect}->{email}; my @break; push @break, "J" if $opts{break_redirect}->{username}; push @break, "E" if $opts{break_redirect}->{email}; $extra_args{break} = join( ":", @break ); } $extra_args{del} = $self->delete_relationships( %{ $opts{del} } ) if %{ $opts{del} || {} }; $extra_args{from} = $from if $from; $extra_args{to} = $to if $to; my $remote = LJ::isu($user) ? $user : $self; $self->log_event( 'rename', { remote => $remote, %extra_args, } ) unless $self->is_expunged; # if expunged, we don't need this info anymore # also, would error; don't have a cluster 0 } =head2 C<< $self->break_redirects >> Break outgoing redirects. =cut sub break_redirects { my $self = $_[0]; if ( my $renamedto = $self->prop("renamedto") ) { $self->set_prop( renamedto => undef ); $self->log_event( 'redirect', { renamedto => $renamedto, action => 'remove' } ); } } =head2 C<< DW::User::Rename->create_redirect_journal >> Set up a new user which will redirect to an existing one. Don't allow to set redirects for existing users. =cut sub create_redirect_journal { my ( $class, $fromusername, $tousername ) = @_; # we can only create a redirect journal for a nonexistent, a purged user, or a redirecting user my $fromu = LJ::load_user($fromusername); return 0 if $fromu && !( $fromu->is_expunged || $fromu->is_redirect ); return 0 unless LJ::load_user($tousername); # unable to login as this user, because they have an empty password, which is just fine $fromu = LJ::User->create( user => $fromusername, journaltype => "R", # redirect ) unless $fromu; $fromu->set_renamed; $fromu->set_prop( renamedto => $tousername ); $fromu->log_event( 'redirect', { renamedto => $tousername, action => "add" } ); return 1; } =head2 C<< DW::User::Rename->break_email_redirection( $from_user, $to_user ) >> Break email redirection from one user which redirects to another user =cut sub break_email_redirection { my ( $class, $from_user, $to_user ) = @_; my $to_u = LJ::load_user($to_user); my $from_u = LJ::load_user($from_user); return unless $to_u && $from_u; return unless $from_u->is_redirect && $from_u->prop("renamedto") eq $to_u->user; return $from_u->delete_email_alias; } =head2 C<< $self->delete_relationships >> Delete a list of relationships. Returns a string representation of which relationships were deleted. =cut sub delete_relationships { my ( $self, %opts ) = @_; return unless $self->is_personal; if ( $opts{del_trusted_by} ) { foreach ( $self->trusted_by_users ) { $_->remove_edge( $self, trust => {} ); } } if ( $opts{del_watched_by} ) { foreach ( $self->watched_by_users ) { $_->remove_edge( $self, watch => {} ); } } my @watched_comms; if ( $opts{del_watched} ) { foreach ( $self->watched_users ) { if ( $_->is_community ) { push @watched_comms, $_ if $opts{del_communities}; next; } $self->remove_edge( $_, watch => {} ); } } if ( $opts{del_trusted} ) { foreach ( $self->trusted_users ) { $self->remove_edge( $_, trust => {} ); } } # remove admin and community membership edges if ( $opts{del_communities} ) { # we already have a list of watched communities if we'd fetched the list of journals we watch unless ( $opts{del_watched} ) { foreach ( $self->watched_users ) { push @watched_comms, $_ if $_->is_community; } } foreach (@watched_comms) { $self->remove_edge( $_, watch => {} ); } my @ids = $self->member_of_userids; my $memberships = LJ::load_userids(@ids) || {}; foreach ( values %$memberships ) { $self->leave_community($_); } } my @del; push @del, "TB" if $opts{del_trusted_by}; push @del, "WB" if $opts{del_watched_by}; push @del, "T" if $opts{del_trusted}; push @del, "W" if $opts{del_watched}; push @del, "C" if $opts{del_communities}; return join ":", @del; } =head2 C<< $self->_rename_to_ex( $tousername ) >> Internal function to do renames away from the current username. Low-level, no error-checking on inputs. Accepts a username, renames the user to a form of ex_oldusernam123. =cut sub _rename_to_ex { my ( $u, %opts ) = @_; my $errors = $opts{errors} || DW::FormErrors->new; my $dbh = LJ::get_db_writer() or die "Could not get DB handle"; # move the current username out of the way, if it's an existing user my $tries = 0; while ( $tries < 10 ) { # take the first nineteen characters of the old username + a random number my $ex_user = substr( $u->user, 0, 19 ) . int( rand(999) ); # do the rename if the user doesn't already exist return DW::User::Rename::_rename( $u, "ex_$ex_user", redirect => 0, token => DW::RenameToken->create_token( systemtoken => 1 ) ) unless $dbh->selectrow_array( "SELECT COUNT(*) from user WHERE user=?", undef, $ex_user ); $tries++; } $errors->add( '', "rename.ex.toomanytries", { tousername => $u->user } ); return 0; } *LJ::User::can_rename_to = \&can_rename_to; *LJ::User::rename = \&rename; *LJ::User::swap_usernames = \&swap_usernames; *LJ::User::apply_rename_opts = \&apply_rename_opts; *LJ::User::break_redirects = \&break_redirects; *LJ::User::delete_relationships = \&delete_relationships; =head1 BUGS =head1 AUTHORS Afuna =head1 COPYRIGHT AND LICENSE Copyright (c) 2010-2014 by Dreamwidth Studios, LLC. This program is free software; you may redistribute it and/or modify it under the same terms as Perl itself. For a copy of the license, please reference 'perldoc perlartistic' or 'perldoc perlgpl'. =cut 1;