mourningdove/cgi-bin/DW/Controller/Community.pm
2026-05-24 01:03:05 +00:00

1479 lines
50 KiB
Perl

#!/usr/bin/perl
#
# Authors:
# Afuna <coder.dw@afunamatata.com>
#
# Copyright (c) 2013-2018 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::Controller::Community;
use strict;
use DW::Controller;
use DW::Routing;
use DW::Template;
use DW::FormErrors;
use POSIX;
use List::MoreUtils qw/ uniq /;
use DW::Entry::Moderated;
=head1 NAME
DW::Controller::Community - Community management pages
=cut
DW::Routing->register_string( "/communities/index", \&index_handler, app => 1 );
DW::Routing->register_string( "/communities/list", \&list_handler, app => 1 );
DW::Routing->register_string( "/communities/new", \&new_handler, app => 1 );
DW::Routing->register_string( "/communities/convert", \&convert_handler, app => 1 );
DW::Routing->register_regex( '^/communities/([^/]+)/members/new$', \&members_new_handler,
app => 1 );
DW::Routing->register_regex( '^/communities/([^/]+)/members/edit$',
\&members_edit_handler, app => 1 );
DW::Routing->register_string(
"/communities/members/purge", \&members_purge_handler,
app => 1,
methods => { POST => 1 }
);
DW::Routing->register_regex( '^/communities/([^/]+)/queue/entries$',
\&entry_queue_handler, app => 1 );
DW::Routing->register_regex( '^/communities/([^/]+)/queue/entries/([0-9]+)$',
\&entry_queue_edit_handler, app => 1 );
DW::Routing->register_regex( '^/communities/([^/]+)/queue/members$',
\&members_queue_handler, app => 1 );
DW::Routing->register_regex(
'^/approve/(\d+)\.(.+)$', \&member_approve_handler,
app => 1,
no_cache => 1
);
DW::Routing->register_regex(
'^/reject/(\d+)\.(.+)$', \&member_reject_handler,
app => 1,
no_cache => 1
);
# redirects
DW::Routing->register_redirect( "/community/index", "/communities/index" );
DW::Routing->register_redirect( "/community/manage", "/communities/list" );
DW::Routing->register_redirect( "/community/create", "/communities/new" );
DW::Routing->register_redirect( "/community/sentinvites", "/communities/members/new",
keep_args => ["authas"] );
DW::Routing->register_string( "/communities/members/new", \&members_new_redirect_handler,
app => 1 );
DW::Routing->register_redirect( "/community/members", "/communities/members/edit",
keep_args => ["authas"] );
DW::Routing->register_string( "/communities/members/edit", \&members_redirect_handler, app => 1 );
DW::Routing->register_redirect( "/community/moderate", "/communities/queue/entries",
keep_args => ["authas"] );
DW::Routing->register_string( "/communities/queue/entries", \&entry_queue_redirect_handler,
app => 1 );
DW::Routing->register_redirect( "/community/pending", "/communities/queue/members",
keep_args => ["authas"] );
DW::Routing->register_string( "/communities/queue/members", \&member_queue_redirect_handler,
app => 1 );
sub _redirect_authas {
my $redirect_path = $_[0];
my $r = DW::Request->get;
my $get = $r->get_args;
my $authas = LJ::eurl( $get->{authas} );
if ($authas) {
return $r->redirect("$LJ::SITEROOT/communities/$authas/$redirect_path");
}
else {
return $r->redirect("$LJ::SITEROOT/communities/list");
}
}
sub members_new_redirect_handler { return _redirect_authas("members/new"); }
sub members_redirect_handler { return _redirect_authas("members/edit"); }
sub entry_queue_redirect_handler { return _redirect_authas("queue/entries"); }
sub member_queue_redirect_handler { return _redirect_authas("queue/members"); }
sub index_handler {
my ( $ok, $rv ) = controller( anonymous => 1 );
return $rv unless $ok;
my $vars = {
remote => $rv->{remote},
remote_admins_communities => @{ LJ::load_rel_target( $rv->{remote}, 'A' ) || [] } ? 1 : 0,
community_manage_links => LJ::Hooks::run_hook('community_manage_links') || "",
# implemented as a hook because most/all the links are to
# dreamwidth.org-specific FAQs. see cgi-bin/DW/Hooks/Community.pm
# in dw-nonfree as an example to create your own.
faq_links => LJ::Hooks::run_hook('community_faqs') || "",
# hook is to list dw-community-promo;
# define your own in a hook if you have a similar community or want to
# add other links to the list.
community_search_links => LJ::Hooks::run_hook('community_search_links') || "",
recently_active_comms => DW::Widget::RecentlyActiveComms->render,
newly_created_comms => DW::Widget::NewlyCreatedComms->render,
official_comms => LJ::Hooks::run_hook('official_comms') || "",
};
return DW::Template->render_template( 'communities/index.tt', $vars );
}
sub list_handler {
my ( $ok, $rv ) = controller();
return $rv unless $ok;
my $remote = $rv->{remote};
my @comms_managed = $remote->communities_managed_list;
my @comms_moderated = $remote->communities_moderated_list;
# 'foo' => {
# user => 'foo'
# ljuser => '<.... user=foo>'
# title => 'Community for Foo Enthusiasts',
# mod_queue_count => 123,
# pending_members_count => 456,
# }
my %communities;
foreach my $cu ( @comms_managed, @comms_moderated ) {
$communities{ $cu->user } = {
user => $cu->user,
ljuser => $cu->ljuser_display,
title => $cu->name_raw,
moderation_queue_url => $cu->moderation_queue_url,
member_queue_url => $cu->member_queue_url,
};
}
foreach my $cu (@comms_managed) {
my $comm_representation = $communities{ $cu->user };
$comm_representation->{admin} = 1;
my $pending_members =
$cu->is_moderated_membership
? $cu->get_pending_members_count
: 0;
$comm_representation->{pending_members_count} = $pending_members;
}
foreach my $cu (@comms_moderated) {
my $comm_representation = $communities{ $cu->user };
$comm_representation->{moderator} = 1;
# we don't rely on $cu->has_moderated_posting
# because we may still have posts in the queue
# e.g., after a switch from moderated posting to non-moderated posting
my $mod_queue = $cu->get_mod_queue_count;
my $should_show_queue = $cu->has_moderated_posting || $mod_queue;
$comm_representation->{show_mod_queue_count} = $should_show_queue;
$comm_representation->{mod_queue_count} = $cu->get_mod_queue_count
if $should_show_queue;
}
my @sorted_communities = sort { $a cmp $b }
keys %communities;
my $vars = { community_list => [ @communities{@sorted_communities} ], };
return DW::Template->render_template( 'communities/list.tt', $vars );
}
sub _enforce_valid_settings {
my ( $post, $default_options, $validate_age_restriction ) = @_;
# checks that the POSTed option is valid
# if not, force it to the default option
my $validate = sub {
my ( $key, $regex ) = @_;
$post->set( $key, $default_options->{$key} )
unless defined $post->{$key} && $post->{$key} =~ $regex;
};
$validate->( "membership", qr/^(?:open|moderated|closed)$/ );
$validate->( "postlevel", qr/^(?:members|select)$/ );
$validate->( "nonmember_posting", qr/^[01]$/ );
$validate->( "moderated", qr/^[01]$/ );
$validate->( "age_restriction", qr/^(?:none|concepts|explicit)$/ )
if $validate_age_restriction;
return $post;
}
sub new_handler {
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $remote = $rv->{remote};
my $r = $rv->{r};
my $post;
my $get;
return error_ml('bml.badinput.body1') unless LJ::text_in($post);
return error_ml('/communities/new.tt.error.notactive') unless $remote->is_visible;
return error_ml(
'/communities/new.tt.error.notconfirmed',
{
confirm_url => "$LJ::SITEROOT/register",
}
) unless $remote->is_validated;
my %default_options = (
membership => 'open',
postlevel => 'members',
moderated => '0',
nonmember_posting => '0',
age_restriction => 'none'
);
my $errors = DW::FormErrors->new;
if ( $r->did_post ) {
$post = _enforce_valid_settings( $r->post_args, \%default_options, 1 );
my $new_user = LJ::canonical_username( $post->{user} );
my $title = $post->{title} || $new_user;
if ( LJ::sysban_check( 'email', $remote->email_raw ) ) {
LJ::Sysban::block(
0,
"Create user blocked based on email",
{ new_user => $new_user, email => $remote->email_raw, name => $new_user }
);
return $r->HTTP_SERVICE_UNAVAILABLE;
}
if ( !$post->{user} ) {
$errors->add( "user", ".error.user.mustenter" );
}
elsif ( !$new_user ) {
$errors->add( "user", "error.usernameinvalid" );
}
elsif ( length $new_user > 25 ) {
$errors->add( "user", "error.usernamelong" );
}
# disallow creating communities matched against the deny list
$errors->add( "user", ".error.user.reserved" )
if LJ::User->is_protected_username($new_user);
# now try to actually create the community
my $second_submit;
my $cu = LJ::load_user($new_user);
if ( $cu && $cu->is_expunged ) {
$errors->add(
"user",
"widget.createaccount.error.username.purged",
{ aopts => "href='$LJ::SITEROOT/rename/'" }
);
}
elsif ($cu) {
# community was created in the last 10 minutes?
my $recent_create = ( $cu->timecreate > ( time() - ( 10 * 60 ) ) ) ? 1 : 0;
$second_submit =
( $cu->is_community && $recent_create && $remote->can_manage_other($cu) ) ? 1 : 0;
$errors->add( "user", ".error.user.inuse" ) unless $second_submit;
}
unless ( $errors->exist ) {
# rate limit
return error_ml("/communities/new.tt.error.ratelimited")
unless $remote->rate_log( 'commcreate', 1 );
$cu = LJ::User->create_community(
user => $new_user,
status => $remote->email_status,
name => $title,
email => $remote->email_raw,
membership => $post->{membership},
postlevel => $post->{postlevel},
moderated => $post->{moderated},
nonmember_posting => $post->{nonmember_posting},
journal_adult_settings => $post->{age_restriction},
) unless $second_submit;
return DW::Controller->render_success(
'communities/new.tt',
{ user => $cu->ljuser_display },
[
{
text_ml => ".success.link.settings",
url => LJ::create_url(
"/manage/settings/", args => { authas => $cu->user, cat => "community" }
),
},
{
text_ml => ".success.link.profile",
url =>
LJ::create_url( "/manage/profile/", args => { authas => $cu->user } ),
},
{
text_ml => ".success.link.customize",
url => LJ::create_url( "/customize/", args => { authas => $cu->user } ),
},
]
) if $cu;
}
}
else {
$get = $r->get_args;
}
my $vars = {
age_restriction_enabled => LJ::is_enabled('adult_content'),
errors => $errors,
};
$vars->{formdata} = $post || {
user => $get->{user},
title => $get->{title},
# initial radio button selection
%default_options
};
return DW::Template->render_template( 'communities/new.tt', $vars );
}
sub convert_handler {
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $remote = $rv->{remote};
my $r = $rv->{r};
my $post;
# more private / restricted than when we create a new community from scratch
my %default_options = (
membership => 'closed',
postlevel => 'select',
moderated => '0',
nonmember_posting => '0',
age_restriction => 'none',
);
my $errors = DW::FormErrors->new;
if ( $r->did_post ) {
$post = _enforce_valid_settings( $r->post_args, \%default_options );
my $cuser = LJ::canonical_username( $post->{cuser} );
my $cu = LJ::load_user($cuser);
if ($cu) {
$errors->add( "cuser", ".error.alreadycomm", { comm => $cu->ljuser_display } )
if $cu->is_community;
$errors->add( "cuser", ".error.samenames" )
if $cu->equals($remote);
}
else {
$errors->add( "cuser", ".error.notfound" ) unless $cu;
}
# only check the password if we have no errors so far
unless ( $errors->exist ) {
$errors->add( "cpassword", ".error.badpassword" )
if !LJ::auth_okay( $cu, $post->{cpassword} );
}
# disallow changing the journal type if the journal has entries
if ( !$errors->exist && !$remote->has_priv( "changejournaltype", "" ) ) {
my $count;
my $userid = $cu->{'userid'} + 0;
my $dbcr = LJ::get_cluster_reader($cu);
$count = $dbcr->selectrow_array(
"SELECT COUNT(*) FROM log2 WHERE journalid=$userid AND posterid=journalid");
$errors->add( "cuser", ".error.hasentries", { user => $cu->ljuser_display } )
if $count;
}
# disallow changing the journal type if the journal administers any communities
unless ( $errors->exist ) {
my $admin_of_count = scalar( $cu->communities_managed_list ) || 0;
$errors->add( "cuser", ".error.hascommadmin", { count => $admin_of_count } )
if $admin_of_count;
}
unless ( $errors->exist ) {
$cu->update_self( { journaltype => 'C', password => '' } );
$cu->invalidate_directory_record;
# handle admin edges
LJ::set_rel( $cu, $remote, 'A' );
LJ::set_rel( $cu->userid, $remote->userid, 'M' )
if $post->{moderated} && !LJ::load_rel_user( $cu->userid, 'M' )->[0];
# set community settings
$cu->set_comm_settings(
$remote,
{
membership => $post->{membership},
postlevel => $post->{postlevel},
}
);
$cu->set_prop(
{
nonmember_posting => $post->{nonmember_posting},
moderated => $post->{moderated},
}
);
# delete existing watchlist & trustlist
foreach ( $cu->watched_users ) {
$cu->remove_edge( $_, watch => {} );
}
foreach ( $cu->trusted_users ) {
$cu->remove_edge( $_, trust => {} );
}
# log this to statushistory
my $msg = "account '" . $cu->user . "' converted to community";
$msg .= " (maintainer is '" . $remote->user . "')";
LJ::statushistory_add( $cu, $remote, "change_journal_type", $msg );
# lazy-cleanup: if a community has subscriptions (most likely
# due to a personal->comm conversion), nuke those subs.
# (since they can't manage them anyway!)
$cu->delete_all_subscriptions;
# ... and migrate their interests to the right table
$cu->lazy_interests_cleanup;
LJ::Hooks::run_hook( "change_journal_type", $cu );
return DW::Controller->render_success(
'communities/convert.tt',
{ comm => $cu->ljuser_display },
[
{
text_ml => ".success.link.settings",
url => LJ::create_url(
"/manage/settings/", args => { authas => $cu->user, cat => "community" }
),
},
{
text_ml => ".success.link.profile",
url =>
LJ::create_url( "/manage/profile/", args => { authas => $cu->user } ),
},
{
text_ml => ".success.link.customize",
url => LJ::create_url( "/customize/", args => { authas => $cu->user } ),
},
]
);
}
}
my $vars = {
errors => $errors,
formdata => $post || \%default_options,
admin_user => $remote->ljuser_display,
};
return DW::Template->render_template( 'communities/convert.tt', $vars );
}
sub _revoke_invitation {
my ( $cu, $post, $errors ) = @_;
my $target_uid = $post->{revoke} + 0;
my $target_u = LJ::load_userid($target_uid);
$errors->add( undef, "error.nojournal" ) and return unless $target_u;
$cu->revoke_invites( $target_u->userid );
}
sub _invite_new_member {
my ( $cu, $post, $errors, %opts ) = @_;
my $remote = $opts{remote};
my $num_rows = $opts{rows};
my $default_checked_roles = $opts{default_roles};
my $form_to_invite_attrib = $opts{form_to_invite_attrib};
foreach my $num ( 1 .. $num_rows ) {
my $user_field = "user_$num";
my $role_field = "user_role_$num";
my $given_user = LJ::ehtml( LJ::trim( $post->{$user_field} ) );
next unless $given_user;
my $invited_u = LJ::load_user_or_identity($given_user);
$errors->add( $user_field, ".error.no_user", { user => $given_user } ) and next
unless $invited_u;
$errors->add( $user_field, ".error.not_active", { user => $invited_u->ljuser_display } )
and next
unless $invited_u->is_visible;
my @roles_for_user = $post->get_all($role_field);
$errors->add( $user_field, ".error.no_role", { user => $invited_u->ljuser_display } )
and next
unless @roles_for_user;
$errors->add(
$user_field,
".error.invalid_journaltype",
{
user => $invited_u->ljuser_display,
type => $invited_u->journaltype_readable
}
)
and next
unless $invited_u->is_individual;
$errors->add( $user_field, ".error.already_added", { user => $invited_u->ljuser_display } )
and next
if $invited_u->member_of($cu);
my $adult_content;
unless ( $invited_u->can_join_adult_comm( comm => $cu, adultref => \$adult_content ) ) {
$errors->add( $user_field, ".error.is_minor", { user => $invited_u->ljuser_display } )
and next
if $adult_content eq "explicit";
}
# turn on posting access according to community settings
my $post_level = $cu->post_level;
push @roles_for_user, "poster"
if $post_level eq 'members';
# all good, let's extend an invite to this person
# these map the form field POSTed to the form expected in send_comm_invite
my @attribs = map { $form_to_invite_attrib->{$_} } uniq @roles_for_user;
if ( $invited_u->send_comm_invite( $cu, $remote, \@attribs ) ) {
# succeeded, clear from the form so they don't display again
$post->remove($user_field);
$post->set( "user_role_$num", @$default_checked_roles );
}
else {
my $error_ml = {
"comm_user_has_banned" => '.error.banned',
"comm_invite_limit" => '.error.limit'
}->{ LJ::last_error_code() };
$error_ml ||= ".error.unknown";
$errors->add( $user_field, $error_ml, { user => $invited_u->ljuser_display } );
}
}
}
sub members_new_handler {
my ( $opts, $community ) = @_;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $cu = LJ::load_user($community);
return error_ml("error.nocomm") unless $cu;
return error_ml(
"error.communities.notcomm",
{
user => $cu->ljuser_display,
}
) unless $cu->is_comm;
my $remote = $rv->{remote};
return error_ml(
"error.communities.noaccess",
{
comm => $cu->ljuser_display,
}
) unless $remote->can_manage_other($cu);
my $r = $rv->{r};
my $get = $r->get_args;
my $num_rows = 5;
my @default_checked_roles = qw( member poster );
my %form_to_invite_attrib = (
admin => 'admin',
poster => 'post',
member => 'member',
moderator => 'moderate',
unmoderated => 'preapprove'
);
my %invite_attrib_to_form = reverse %form_to_invite_attrib;
my $errors = DW::FormErrors->new;
my $post;
if ( $r->did_post ) {
$post = $r->post_args;
if ( $post->{revoke} ) {
_revoke_invitation( $cu, $post, $errors, );
}
else {
_invite_new_member(
$cu, $post, $errors,
remote => $remote,
rows => $num_rows,
form_to_invite_attrib => \%form_to_invite_attrib,
default_roles => \@default_checked_roles,
);
}
}
# figure out what member roles are relevant
my @available_roles = ('member');
push @available_roles, 'poster'
if $cu->post_level eq 'select';
push @available_roles, qw( unmoderated moderator )
if $cu->has_moderated_posting;
push @available_roles, 'admin';
# now get sent invites and the users involved
my $sent = $cu->get_sent_invites || [];
my @ids;
push @ids, ( $_->{userid}, $_->{maintid} ) foreach @$sent;
my $us = LJ::load_userids(@ids);
# filter by status if desired
my @valid_statuses = qw( outstanding accepted rejected );
my %valid_statuses = map { $_ => 1 } @valid_statuses;
my $status_filter = $get->{status} || '';
$status_filter = "all" unless $valid_statuses{$status_filter};
my @filters = (
{
text => ".invite.filter.all",
url => LJ::create_url(undef),
active => $status_filter eq "all",
}
);
push @filters,
{
text => ".invite.filter.$_",
url => LJ::create_url( undef, args => { status => $_ } ),
active => $status_filter eq $_,
} foreach @valid_statuses;
# populate %users hash
my %users = ();
my %usernames;
my @available_roles_for_invite = map { $form_to_invite_attrib{$_} } @available_roles;
foreach my $invite (@$sent) {
my $id = $invite->{userid};
next unless $status_filter eq 'all' || $status_filter eq $invite->{status};
my $name = $us->{$id}{user};
$users{$id}{userid} = $id;
$users{$id}{invited_by} = LJ::ljuser( $us->{ $invite->{maintid} }{user} );
$users{$id}{user} = LJ::ljuser($name);
$users{$id}{roles} = [
map { $invite_attrib_to_form{$_} }
grep { $invite->{args}->{$_} } @available_roles_for_invite
];
$users{$id}{status} = $invite->{status};
$users{$id}{date} = LJ::diff_ago_text( $invite->{recvtime} );
}
my $page = int( $get->{page} || 0 ) || 1;
my $page_size = 100;
my @users = sort { $a->{user} cmp $b->{user} } values %users;
my $total_pages = ceil( scalar @users / $page_size );
@users = _items_for_this_page( $page, $page_size, @users );
# if we invited new members, respect the checkboxes from the form submission
# if we revoked an invitation, use the default roles
# if we just loaeded the page, use the default roles
my $formdata =
$post && !$post->{revoke}
? $post
: { map { "user_role_" . $_ => \@default_checked_roles } ( 1 .. $num_rows ) };
my $vars = {
roles => \@available_roles,
rows => $num_rows,
has_active_filter => $status_filter eq "all" ? 0 : 1,
sentinvite_filters => \@filters,
sentinvite_pages => { current => $page, total_pages => $total_pages },
sentinvite_list => \@users,
formdata => $formdata,
errors => $errors,
form_invite_action_url => LJ::create_url(undef),
form_revoke_action_url => LJ::create_url( undef, keep_args => [qw( status page )] ),
linkbar => _community_menu(
$remote, $cu,
current_page => 'invites',
path => "/communities/members/new"
),
};
return DW::Template->render_template( 'communities/members/new.tt', $vars );
}
sub members_edit_handler {
my ( $opts, $cuser ) = @_;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $get = $r->get_args;
# now get lists of: members, admins, able to post, moderators
my %roletype_to_readable = _roletype_map();
my %readable_to_roletype = reverse %roletype_to_readable;
my @roles = keys %readable_to_roletype;
my $cu = LJ::load_user($cuser);
return error_ml("error.nocomm") unless $cu;
return error_ml(
"error.communities.notcomm",
{
user => $cu->ljuser_display,
}
) unless $cu->is_comm;
return error_ml(
"/communities/members/edit.tt.error.noaccess",
{
comm => $cu->ljuser_display,
}
) unless $remote->can_manage_other($cu);
# handle post
my @messages;
my @roles_changed;
my $errors = DW::FormErrors->new;
if ( $r->did_post ) {
my $post = $r->post_args;
my %was;
my %current;
foreach my $role (@roles) {
# quick lookup for checkboxes that were checked on page load (old values{})
foreach my $uid ( $post->get_all( $role . "_old" ) ) {
$was{$uid}->{$role} = 1;
}
# and same for current values
foreach my $uid ( $post->get_all($role) ) {
$current{$uid}->{$role} = 1;
}
}
# preload the users we're dealing with
# assumes that every user has at least one checked checkbox...
# but that seems to be a fair assumption
my @preload_userids = grep { $_ } map { $_ + 0 } keys %was;
my %us = %{ LJ::load_userids(@preload_userids) };
# now compare userids in %current to %was
# to determine which to add and which to delete
my ( %add, %delete ); # role -> userid mapping
my ( %add_user_to_role, %delete_user_to_role ); # userid -> role mappings
foreach my $uid (@preload_userids) {
foreach my $role (@roles) {
if ( $current{$uid}->{$role} && !$was{$uid}->{$role} ) {
$add{$role}->{$uid} = 1;
$add_user_to_role{$uid}->{$role} = 1;
}
elsif ( $was{$uid}->{$role} && !$current{$uid}->{$role} ) {
$delete{$role}->{$uid} = 1;
$delete_user_to_role{$uid}->{$role} = 1;
}
}
}
########
## ADD
# members are a special-case, because we need to ask permission first
foreach my $uid ( keys %{ $add{member} || {} } ) {
my $add_u = $us{$uid};
next unless $add_u;
if ( $remote->equals($add_u) ) {
# you're allowed to add yourself as member
$remote->join_community($cu);
}
else {
if ( $add_u && $add_u->send_comm_invite( $cu, $remote, ['member'] ) ) {
push @messages,
[
".msg.invite",
{
user => $add_u->ljuser_display,
invite_url => "$LJ::SITEROOT/manage/invites"
}
];
}
}
}
# admins also need special handling: they should be notified that they've been added
foreach my $uid ( keys %{ $add{admin} || {} } ) {
my $add_u = $us{$uid};
next unless $add_u;
$cu->notify_administrator_add( $add_u, $remote );
}
# go ahead and add poster (P), unmoderated (N), moderator (M), admin (A) edges unconditionally
my $cid = $cu->userid;
LJ::set_rel_multi(
( map { [ $cid, $_, 'A' ] } keys %{ $add{admin} || {} } ),
( map { [ $cid, $_, 'P' ] } keys %{ $add{poster} || {} } ),
( map { [ $cid, $_, 'M' ] } keys %{ $add{moderator} || {} } ),
( map { [ $cid, $_, 'N' ] } keys %{ $add{unmoderated} || {} } ),
);
##########
## DELETE
# delete members
foreach my $uid ( keys %{ $delete{member} || {} } ) {
my $del_u = $us{$uid};
next unless $del_u;
$del_u->remove_edge( $cid, member => {} );
}
# admins are a special case: we need to make sure we don't remove all admins from the community
# we load the admin_users in bulk separately, because this list might include admins that weren't available on this page
# (but we still want to be able to load them up to check their visibility status)
my %admin_users = %{ LJ::load_userids( $cu->maintainer_userids ) };
my %admins_to_delete = %{ $delete{admin} || {} };
my @remaining_admins = grep {
!$admins_to_delete{$_} # admins we want to delete on this page load
&& $admin_users{$_} # is an existing user
&& !$admin_users{$_}->is_expunged # that is not expunged
} $cu->maintainer_userids;
unless (@remaining_admins) {
$errors->add( "admin", ".error.no_admin", { comm => $cu->ljuser_display } );
# refuse to delete any admins
$delete{admin} = {};
}
# now notify admins that we're deleting
foreach my $uid ( keys %{ $delete{admin} || {} } ) {
my $del_u = $us{$uid};
next if !$del_u || $del_u->is_expunged;
$cu->notify_administrator_remove( $del_u, $remote );
}
# go ahead and delete poster (P), unmoderated (N), moderator (M), admin (A) edges unconditionally
LJ::clear_rel_multi(
( map { [ $cid, $_, 'A' ] } keys %{ $delete{admin} || {} } ),
( map { [ $cid, $_, 'P' ] } keys %{ $delete{poster} || {} } ),
( map { [ $cid, $_, 'M' ] } keys %{ $delete{moderator} || {} } ),
( map { [ $cid, $_, 'N' ] } keys %{ $delete{unmoderated} || {} } ),
);
###############
## CLEAR CACHE
# delete reluser memcache key
LJ::MemCache::delete( [ $cid, "reluser:$cid:A" ] );
LJ::MemCache::delete( [ $cid, "reluser:$cid:P" ] );
LJ::MemCache::delete( [ $cid, "reluser:$cid:M" ] );
LJ::MemCache::delete( [ $cid, "reluser:$cid:N" ] );
####################
## SUCCESS MESSAGES
# now show messages for each succesful change we did
my %done;
my %role_strings = map { $_ => LJ::Lang::ml("/communities/members/edit.tt.role.$_") }
%readable_to_roletype;
my $remote_uid = $remote->userid;
foreach my $uid ( keys %add_user_to_role, keys %delete_user_to_role ) {
next if $done{$uid}++;
my $u = $us{$uid};
next unless $u;
if ( $post->{"action:purge"} ) {
push @roles_changed, { user => $u->ljuser_display, purged => 1 };
}
else {
my ( $changed_roles_msg, @added_roles, @removed_roles );
push @added_roles, $role_strings{$_} foreach grep {
$_ ne
"member" # reinvited members need to confirm, so we don't want a success message
|| $uid ==
$remote_uid # but if you're adding yourself as a member, that's fine
} keys %{ $add_user_to_role{$uid} || {} };
push @removed_roles, $role_strings{$_}
foreach keys %{ $delete_user_to_role{$uid} || {} };
push @roles_changed,
{
user => $u->ljuser_display,
added => \@added_roles,
removed => \@removed_roles
}
if @added_roles || @removed_roles;
}
}
}
my @role_filters = split ",", $get->{role} || "";
@role_filters = grep { $_ } # make sure it's a valid role
map { $readable_to_roletype{$_} } @role_filters;
my %active_role_filters = map { $roletype_to_readable{$_} => 1 } @role_filters;
my ( $users, $role_count );
if ( $get->{q} ) {
# we return results for just this user
my $query_u = LJ::load_user( $get->{q} );
( $users, $role_count ) = $cu->get_member($query_u);
}
else {
# grab the list of users (optionally by role)
( $users, $role_count ) = $cu->get_members_by_role( \@role_filters );
}
my $page = int( $get->{page} || 0 ) || 1;
my $page_size = 100;
my @users = sort { $a->{name} cmp $b->{name} } values %$users;
# pagination:
# calculate the number of pages
# take the results and choose only a slice for display
my $total_pages = ceil( scalar @users / $page_size );
@users = _items_for_this_page( $page, $page_size, @users );
# populate with the ljuser tag for display
$_->{ljuser} = LJ::ljuser( $_->{name} ) foreach @users;
# figure out what member roles are relevant
my @available_roles = ('member');
push @available_roles, 'poster'
if $cu->post_level eq 'select' || $role_count->{P};
my $has_moderated_posting = $cu->has_moderated_posting;
push @available_roles, 'unmoderated'
if $has_moderated_posting || $role_count->{N};
push @available_roles, 'moderator'
if $has_moderated_posting || $role_count->{M};
push @available_roles, 'admin';
# create a data structure for the links to filter members
my $filter_link = sub {
my $filter = $_[0];
return {
text => ".role.$filter",
url => LJ::create_url( undef, args => { role => "$filter" } ),
active => $active_role_filters{$filter} ? 1 : 0,
},
;
};
my @filter_links = (
{
text => ".role.all",
url => LJ::create_url(undef),
active => ( scalar keys %active_role_filters ) || $get->{q} ? 0 : 1,
}
);
push @filter_links, $filter_link->($_) foreach @available_roles;
# data for the checkboxes in the form of:
# {
# role => [ userids ], ...
# }
my $membership_statuses = Hash::MultiValue->new;
my @roletype_keys = keys %roletype_to_readable;
foreach my $user ( values %$users ) {
foreach my $roletype (@roletype_keys) {
$membership_statuses->add( $roletype_to_readable{$roletype}, $user->{userid} )
if $user->{$roletype};
}
}
my $vars = {
community => $cu,
user_list => \@users,
roles => \@available_roles,
filter_links => \@filter_links,
pages => { current => $page, total_pages => $total_pages },
has_active_filter => keys %active_role_filters ? 1 : 0,
formdata => $membership_statuses,
messages => \@messages,
roles_changed => \@roles_changed,
errors => $errors,
form_edit_action_url => LJ::create_url( undef, keep_args => [qw( role page )] ),
form_search_action_url => LJ::create_url(undef),
form_purge_action_url => LJ::create_url("/communities/members/purge"),
linkbar => _community_menu(
$remote, $cu,
current_page => 'members',
path => '/communities/members/edit'
),
};
return DW::Template->render_template( 'communities/members/edit.tt', $vars );
}
sub members_purge_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 0 );
return $rv unless $ok;
my $r = $rv->{r};
my $post = $r->post_args;
my $remote = $rv->{remote};
my $cu = LJ::load_user( $post->{authas} );
return error_ml("error.nocomm") unless $cu;
return error_ml(
"error.communities.notcomm",
{
user => $cu->ljuser_display,
}
) unless $cu->is_comm;
return error_ml(
"error.communities.noaccess",
{
comm => $cu->ljuser_display,
}
) unless $remote->can_manage_other($cu);
my $members = LJ::load_userids( $cu->member_userids );
my @purged = map { name => $_->ljuser_display, id => $_->userid },
sort { $a->user cmp $b->user }
grep { $_ && $_->is_expunged } values %$members;
my $members_url = LJ::create_url( "/communities/" . $cu->user . "/members/edit" );
my $vars = {
user_list => \@purged,
community => $cu,
roles => [qw( admin poster member moderator unmoderated )],
form_action => $members_url,
members_url => $members_url,
linkbar => _community_menu( $remote, $cu, path => '/communities/members/edit' ),
};
return DW::Template->render_template( 'communities/members/purge.tt', $vars );
}
sub entry_queue_handler {
my ( $opts, $community ) = @_;
my ( $ok, $rv ) = controller( form_auth => 0 );
return $rv unless $ok;
my $cu = LJ::load_user($community);
my ( $can_moderate, @error ) = _check_entry_queue_auth( $cu, $rv->{remote} );
return error_ml(@error) unless $can_moderate;
my $r = $rv->{r};
my @queue = $cu->get_mod_queue;
my %users;
LJ::load_userids_multiple( [ map { $_->{posterid}, \$users{ $_->{posterid} } } @queue ] );
my @entries = map {
{
time => LJ::diff_ago_text( LJ::mysqldate_to_time( $_->{logtime} ) ),
poster => $users{ $_->{posterid} }->ljuser_display,
subject => $_->{subject},
subject_maxlength => ( length( $_->{subject} ) >= 29 ) ? 1 : 0,
url => $cu->moderation_queue_url( $_->{modid} ),
}
} @queue;
my $vars = {
entries => \@entries,
linkbar => _community_menu(
LJ::get_remote(), $cu,
current_page => 'queue',
path => '/communities/queue/entries'
),
community => $cu,
};
return DW::Template->render_template( 'communities/queue/entries.tt', $vars );
}
sub entry_queue_edit_handler {
my ( $opts, $community, $modid ) = @_;
$modid = int $modid;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $cu = LJ::load_user($community);
my ( $can_moderate, @error ) = _check_entry_queue_auth( $cu, $rv->{remote} );
return error_ml(@error) unless $can_moderate;
my $moderated_entry = DW::Entry::Moderated->new( $cu, $modid );
return error_ml("/communities/queue/entries/edit.tt.error.no_entry") unless $moderated_entry;
my $r = $rv->{r};
my $errors = DW::FormErrors->new;
if ( $r->did_post ) {
my $post = $r->post_args;
my $status_vars = { queue_url => $cu->moderation_queue_url };
return error_ml("/communities/queue/entries/edit.tt.error.no_entry")
unless $moderated_entry->auth eq $post->{auth};
if ( $post->{"action:approve"} || $post->{"action:preapprove"} ) {
my ( $approve_ok, $approve_rv ) = $moderated_entry->approve;
if ($approve_ok) {
$moderated_entry->notify_poster(
"approved",
entry_url => $approve_rv,
message => $post->{message}
);
$status_vars->{status} = "approved";
$status_vars->{entry_url} = $approve_rv;
}
else {
$moderated_entry->notify_poster( "error", error => $approve_rv );
$errors->add(
undef,
"/communities/queue/entries/edit.tt.error.post",
{ error => $approve_rv }
);
}
if ( $post->{"action:preapprove"} ) {
LJ::set_rel( $moderated_entry->journal, $moderated_entry->poster, 'N' );
$status_vars->{preapproved} = 1;
$status_vars->{user} = $moderated_entry->poster->ljuser_display;
$status_vars->{community} = $moderated_entry->journal->ljuser_display;
}
}
if ( $post->{"action:reject"} || $post->{"action:spam"} ) {
my $reject_ok =
$post->{"action:spam"}
? $moderated_entry->reject_as_spam
: $moderated_entry->reject;
$moderated_entry->notify_poster( "rejected", message => $post->{message}, )
if $reject_ok;
$status_vars->{status} = "rejected";
$errors->add( undef, ".error.cant_spam" ) unless $reject_ok;
}
return DW::Template->render_template( 'communities/queue/entries/edit-status.tt',
$status_vars )
unless $errors->exist;
}
my $vars = {
entry => {
icon => $moderated_entry->icon,
poster => $moderated_entry->poster,
journal => $moderated_entry->journal,
time => $moderated_entry->time( linkify => 1 ),
subject => $moderated_entry->subject,
event => $moderated_entry->event,
age_restriction_reason => $moderated_entry->age_restriction_reason,
auth => $moderated_entry->auth,
currents_html => $moderated_entry->currents_html,
security_html => $moderated_entry->security_html,
age_restriction_html => $moderated_entry->age_restriction_html,
},
moderate_url => LJ::create_url(undef),
can_report_spam => LJ::sysban_check( 'spamreport', $cu->user ) ? 0 : 1,
errors => $errors,
linkbar => _community_menu( LJ::get_remote(), $cu, path => '/communities/queue/entries' ),
};
return DW::Template->render_template( 'communities/queue/entries/edit.tt', $vars );
}
sub members_queue_handler {
my ( $opts, $community ) = @_;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $get = $r->get_args;
my $cu = LJ::load_user($community);
return error_ml("error.nocomm") unless $cu;
return error_ml(
"/communities/queue/members.tt.error.noaccess",
{
comm => $cu->ljuser_display,
}
) unless $remote->can_manage_other($cu);
# now load all users with a pending membership request
my $pendids = $cu->get_pending_members || [];
my $us = LJ::load_userids(@$pendids);
my @success_msgs;
if ( $r->did_post ) {
my $post = $r->post_args;
my @statuses = qw( approve reject ban ban_skip
previously_handled
);
my %status_count = map { $_ => 0 } @statuses;
$post->each(
sub {
my ( $key, $action ) = @_;
my $uid;
return unless ($uid) = $key =~ m/user_(\d+)/;
my $pending_u = $us->{$uid};
if ( !$pending_u ) {
# POSTed but not in pending users. Looks like it was handled by someone else
$status_count{previously_handled}++;
}
elsif ( $action eq "approve" ) {
$cu->approve_pending_member($pending_u);
$status_count{approve}++;
}
elsif ( $action eq "reject" ) {
$cu->reject_pending_member($pending_u);
$status_count{reject}++;
}
elsif ( $action eq "ban" ) {
my $banlist = LJ::load_rel_user( $cu, 'B' ) || [];
if ( scalar(@$banlist) >= ( $LJ::MAX_BANS || 5000 ) ) {
$status_count{ban_skip}++;
}
else {
$cu->ban_user($pending_u);
$status_count{ban}++;
# ban is successful, reject member
$cu->reject_pending_member($pending_u); # only in case of successful ban
}
}
}
);
foreach my $status (@statuses) {
push @success_msgs, { ml => ".success.$status", num => $status_count{$status} }
if $status_count{$status};
}
# get the list of pending members again; may have changed
$pendids = $cu->get_pending_members || [];
$us = LJ::load_userids(@$pendids);
}
my $page = int( $get->{page} || 0 ) || 1;
my $page_size = 100;
my @users = sort { $a->{user} cmp $b->{user} } values %$us;
# pagination:
# calculate the number of pages
# take the results and choose only a slice for display
my $total_pages = ceil( scalar @users / $page_size );
@users = _items_for_this_page( $page, $page_size, @users );
my $vars = {
user_list => [ map { { userid => $_->userid, ljuser => $_->ljuser_display, } } @users ],
pages => { current => $page, total_pages => $total_pages },
messages => \@success_msgs,
form_queue_action_url => LJ::create_url( undef, keep_args => [qw( page )] ),
linkbar => _community_menu( $remote, $cu, path => '/communities/queue/members' ),
};
return DW::Template->render_template( 'communities/queue/members.tt', $vars );
}
sub member_approve_handler {
my ( $opts, $aaid, $auth ) = @_;
my ( $ok, $rv ) = controller( anonymous => 1 );
return $rv unless $ok;
return _member_action_handler( $rv, aaid => $aaid, auth => $auth, action => "approve" );
}
sub member_reject_handler {
my ( $opts, $aaid, $auth ) = @_;
my ( $ok, $rv ) = controller( anonymous => 1 );
return $rv unless $ok;
return _member_action_handler( $rv, aaid => $aaid, auth => $auth, action => "reject" );
}
sub _member_action_handler {
my ( $rv, %opts ) = @_;
my $r = $rv->{r};
my $aaid = $opts{aaid};
my $auth = $opts{auth};
my $action = $opts{action};
my $ml_scope = '/communities/members/action.tt';
my $aa = LJ::is_valid_authaction( $aaid, $auth );
return error_ml( $ml_scope . '.error.invalidargument' )
unless $aa;
return error_ml( $ml_scope . '.error.actionperformed' )
if $aa->{used} eq 'Y';
my $arg = {};
LJ::decode_url_string( $aa->{arg1}, $arg );
my $dbh = LJ::get_db_writer();
# get user we're adding
my $targetu = LJ::load_userid( $arg->{targetid} );
return error_ml( $ml_scope . '.error.internerr.invalidaction' ) unless $targetu;
if ( $aa->{action} eq 'comm_join_request' ) {
# add to community
my $cu = LJ::load_userid( $aa->{userid} );
return error_ml( $ml_scope . '.error.' . $action )
unless $cu;
my $did_succeed;
if ( $action eq "approve" ) {
$did_succeed = $cu->approve_pending_member($targetu);
}
else {
$did_succeed = $cu->reject_pending_member($targetu);
}
return error_ml( $ml_scope . '.error.' . $action )
unless $did_succeed;
return DW::Controller->render_success(
'communities/members/action.tt.' . $action,
{
user => $targetu->ljuser_display,
comm => $cu->ljuser_display,
url => $cu->community_manage_members_url,
}
);
}
}
# convenience methods
# return the appropriate slice from the full array for this page
# ideally we'd do this when fetching from the DB
sub _items_for_this_page {
my ( $page, $page_size, @items ) = @_;
my $first = ( $page - 1 ) * $page_size;
my $num_items = scalar @items;
my $last = $page * $page_size;
$last = $num_items if $last > $num_items;
$last = $last - 1;
return @items[ $first ... $last ];
}
# returns ( $can_moderate, ".error_ml", { error_ml_args => foo } )
sub _check_entry_queue_auth {
my ( $cu, $remote ) = @_;
my $ml_scope = "/communities/queue/entries.tt";
return ( 0, "error.nocomm" ) unless $cu;
unless ( $remote->can_moderate($cu) ) {
return ( 0, "$ml_scope.error.noaccess", { comm => $cu->ljuser_display } )
if $cu->has_moderated_posting;
return ( 0, "$ml_scope.error.notmoderated" );
}
}
sub _roletype_map {
return (
A => 'admin',
P => 'poster',
E => 'member',
M => 'moderator',
N => 'unmoderated'
);
}
sub _community_menu {
my ( $u, $cu, %opts ) = @_;
my $current_page = $opts{current_page} || '';
my $path = $opts{path};
return
"<div class='community-menu panel'>"
. "<form action='"
. LJ::create_url($path)
. "' method='get'>"
. LJ::make_authas_select( $u, { type => 'C', authas => $cu->user, foundation => 1 } )
. "</form>"
. $cu->maintainer_linkbar( $current_page, 1 )
. "</div>";
}
1;