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

705 lines
24 KiB
Perl
Raw Normal View History

2026-05-24 01:03:05 +00:00
#!/usr/bin/perl
#
# DW::Controller::Poll
#
# This controller is for the poll feature
#
# Authors:
# Momiji <momijizukamori@gmail.com>
#
# Copyright (c) 2009-2024 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::Poll;
use strict;
use warnings;
use DW::Controller;
use DW::Routing;
use DW::Template;
use DW::FormErrors;
use LJ::Poll;
DW::Routing->register_string( '/poll', \&index_handler, app => 1, no_redirects => 1 );
DW::Routing->register_string( '/poll/', \&index_handler, app => 1, no_redirects => 1 );
DW::Routing->register_string( '/poll/create', \&create_handler, app => 1 );
sub index_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $form = $r->did_post ? $r->post_args : $r->get_args;
my $remote = $rv->{remote};
# Flatten Hash::MultiValue into a regular hash, joining multiple values
# with commas. This is needed for checkbox poll questions, which submit
# multiple values under the same "pollq-N" key.
my %flat;
foreach my $key ( keys %$form ) {
$flat{$key} = join( ",", $form->get_all($key) );
}
unless ( LJ::text_in( \%flat ) ) {
# $body = "<?badinput?>";
return;
}
my $pollid = ( $flat{'id'} || $flat{'pollid'} ) + 0;
unless ($pollid) {
return $r->redirect("$LJ::SITEROOT/poll/create");
}
my $poll = LJ::Poll->new($pollid);
return error_ml('/poll/index.tt.pollnotfound') unless ( $poll && $poll->valid );
my $u = $poll->journal;
my $mode = "";
$mode = $flat{'mode'}
if ( defined $flat{'mode'} && $flat{'mode'} =~ /(enter|results|ans|clear)/ );
# Handle opening and closing of polls
# We do this first because a closed poll will alter how a poll is displayed
if ( $poll->is_owner($remote) || $remote && $remote->can_manage($u) ) {
if ( defined $flat{'mode'} && $flat{'mode'} =~ /(close|open)/ ) {
$mode = $flat{'mode'};
$poll->close_poll if ( $mode eq 'close' );
$poll->open_poll if ( $mode eq 'open' );
$mode = 'results';
}
}
# load the item being shown
my $entry = $poll->entry;
return error_ml('/poll/index.tt.error.postdeleted') unless ($entry);
return error_ml('/poll/index.tt.error.cantview') unless ( $entry->visible_to($remote) );
# bundle variables to be passed to the template
my $vars = {
remote => $remote,
u => $u,
poll => $poll,
pollid => $pollid,
poll_form => \%flat,
mode => $mode,
entry => $entry,
};
if ( defined $flat{'poll-submit'} && $r->did_post ) {
my $error;
my $error_code = LJ::Poll->process_submission( \%flat, \$error );
if ($error) {
$vars->{error} = $error;
$vars->{error_code} = $error_code;
}
else {
return $r->redirect( $entry->url( style_opts => LJ::viewing_style_opts(%flat) ) );
}
}
return DW::Template->render_template( 'poll/index.tt', $vars );
}
sub create_handler {
my ($opts) = @_;
my ( $ok, $rv ) = controller( form_auth => 1, authas => 1 );
return $rv unless $ok;
my $r = $rv->{r};
my $get = $r->get_args;
my $post = $r->post_args;
my $remote = $rv->{remote};
my $vars = { remote => $remote };
my $ml_scope = "/poll/create.tt";
# some rules used for error checking
my %RULES = (
"elements" => {
"max" => 255, # maximum total number of elements allowed
},
"items" => {
"min" => 1, # minimum number of options
"start" => 5, # number of items shown at start
"max" => 255, # max number of options
"maxlength" => 1000, # max length of an option's textual value, min is implicitly 0
"more" => 5, # number of items to add when requesting more
},
"question" => {
"maxlength" => 1000, # maximum length of question allowed
},
"pollname" => {
"maxlength" => 1000, # maximum length of poll name allowed
},
"text" => {
"size" => 50, # default size of a text element
"maxlength" => 255, # default maxlength of a text element
},
"size" => {
"min" => 1, # minimum allowed size value for a text element
"max" => 100, # maximum allowed size value for a text element
},
"maxlength" => {
"min" => 1, # minimum allowed maxlength value for a text element
"max" => 255, # maximum allowed maxlength value for a text element
},
"scale" => {
"from" => 1, # default from value for a scale
"to" => 10, # default to value for a scale
"by" => 1, # default by value for a scale
"maxitems" => 21, # maximum number of items allowed in a scale
},
"checkbox" => {
"checkmin" =>
0, # number of checkboxes a user must tick in that question (default 0: no limit)
"checkmax" =>
255, # maximum number of checkboxes a user is allowed to tick in that question
},
);
$vars->{rules} = \%RULES;
my $remote_can_make_polls = $remote->can_create_polls;
my $authas = $get->{'authas'} || $remote->{'user'};
my $u;
# If remote can make polls, make sure they can post to the authas journal
# If remote can't make polls, make sure they maintain the authas journal
if ($remote_can_make_polls) {
my $authas_u = LJ::load_user($authas);
$u = $authas_u if $authas_u and $remote->can_post_to($authas_u);
}
else {
$u = LJ::get_authas_user($authas);
}
# Return error if previous check was unsuccessful
return error_ml('error.invalidauth') unless ($u);
# first pageview, show authas
if ( !$r->did_post || $post->{'start_over'} ) {
# postto switcher form
# If remote can make polls, show all communities they have posting access to
# If remote can't make polls, show only paid communities they maintain
my $postto_html = "<form method='get' action='create'>\n";
if ($remote_can_make_polls) {
$postto_html .= LJ::make_authas_select(
$remote,
{
'authas' => $get->{'authas'},
foundation => 1,
'label' => LJ::Lang::ml('web.postto.label'),
'button' => LJ::Lang::ml('web.postto.btn')
}
) . "\n";
}
else {
$postto_html .= LJ::make_authas_select(
$remote,
{
'authas' => $get->{'authas'},
foundation => 1,
'label' => LJ::Lang::ml('web.postto.label'),
'button' => LJ::Lang::ml('web.postto.btn'),
'cap' => 'makepoll',
}
) . "\n";
}
$postto_html .= "</form>\n\n";
$vars->{postto_html} = $postto_html;
}
# does the remote or selected user have the 'makepoll' cap?
unless ( $remote_can_make_polls || $u->can_create_polls ) {
# $body .= "<?h1 $ML{'Sorry'} h1?><?p $ML{'.error.accttype2'} p?>";
# return;
}
# extra arguments for get requests
my $getextra = $authas ne $remote->{'user'} ? "?authas=$authas" : '';
$vars->{getextra} = $getextra;
# variable to store what question the last action took place in
my $focuson = "";
#######################################################
#
# Function definitions
#
# builds a %poll object
my $build_poll = sub {
my $err = shift;
# initialize the hash
my $poll = {
"name" => "",
"count" => "0",
"isanon" => "no",
"whoview" => "all",
"whovote" => "all",
"pq" => [],
};
# make sure they don't plug in an outrageous count
my $post_count = $post->{count} || 0;
$post->{count} = 0 if $post_count < 0;
$post->{count} = $RULES{elements}->{max}
if $post_count > $RULES{elements}->{max};
# form properties
foreach my $it (qw(count name isanon whoview whovote)) {
$poll->{$it} = $post->{$it} if $post->{$it};
}
# go through the count to build our hash
foreach my $q ( 0 .. $poll->{'count'} - 1 ) {
# sanify 'opts' form elements at this level
# so we don't have to do it later
my $opts = "pq_${q}_opts";
$post->{$opts} = 0 if $post->{$opts} && $post->{$opts} < 0;
$post->{$opts} = $RULES{'items'}->{'max'}
if $post->{$opts} > $RULES{'items'}->{'max'};
# question record
my $qrec = {};
# validate question attributes
foreach my $atr (
qw(type question opts size maxlength from to by checkmin checkmax lowlabel highlabel)
)
{
my $val = $post->{"pq_${q}_$atr"};
next
unless defined $val
|| $atr eq 'question'; # 'question' is required, so always check it
# ignore invalid types?
next if $atr eq 'type' && $val !~ /^(radio|check|drop|text|scale)$/;
# question too long/nonexistant
if ( $atr eq 'question' ) {
if ( !$val ) {
$qrec->{$atr} = $val;
$err->{$q}->{$atr} = LJ::Lang::ml("$ml_scope.error.notext");
}
elsif ( length($val) > $RULES{$atr}->{'maxlength'} ) {
$qrec->{$atr} = substr( $val, 0, $RULES{$atr}->{'maxlength'} );
}
else {
$qrec->{$atr} = $val;
}
next;
}
# opts too long?
if ( $atr eq 'opts' ) {
$qrec->{$atr} = int($val);
next;
}
# size too short/long?
if ( $atr eq 'size' ) {
$qrec->{$atr} = int($val);
if ( $qrec->{$atr} > $RULES{$atr}->{'max'}
|| $qrec->{$atr} < $RULES{$atr}->{'min'} )
{
$err->{$q}->{$atr} = LJ::Lang::ml( "$ml_scope.error.pqsizeinvalid2",
{ 'min' => $RULES{$atr}->{'min'}, 'max' => $RULES{$atr}->{'max'} } );
}
next;
}
# maxlength too short/long?
if ( $atr eq 'maxlength' ) {
$qrec->{$atr} = int($val);
if ( $qrec->{$atr} > $RULES{$atr}->{'max'}
|| $qrec->{$atr} < $RULES{$atr}->{'min'} )
{
$err->{$q}->{$atr} = LJ::Lang::ml(
"$ml_scope.error.pqmaxlengthinvalid2",
{
'min' => $RULES{'maxlength'}->{'min'},
'max' => $RULES{'maxlength'}->{'max'}
}
);
}
next;
}
# from/to/by -- scale
if ( $atr eq 'from' ) {
$qrec->{'to'} = int( $post->{"pq_${q}_to"} ) || 0;
$qrec->{'from'} = int( $post->{"pq_${q}_from"} ) || 0;
$qrec->{'by'} =
int( $post->{"pq_${q}_by"} ) >= 1 ? int( $post->{"pq_${q}_by"} ) : 1;
if ( $qrec->{'by'} < $RULES{'by'}->{'min'} ) {
$err->{$q}->{'by'} = LJ::Lang::ml( "$ml_scope.error.scalemininvalid",
{ 'min' => $RULES{'by'}->{'min'} } );
}
if ( $qrec->{'from'} >= $qrec->{'to'} ) {
$err->{$q}->{'from'} = LJ::Lang::ml("$ml_scope.error.scalemaxlessmin");
}
my $scaleoptions = ( ( $qrec->{to} - $qrec->{from} ) / $qrec->{by} ) + 1;
if ( $scaleoptions > $RULES{scale}->{maxitems} ) {
$err->{$q}->{to} = LJ::Lang::ml(
"$ml_scope.error.scaletoobig1",
{
'maxselections' => $RULES{scale}->{maxitems},
'selections' => $scaleoptions - $RULES{scale}->{maxitems}
}
);
}
next;
}
if ( $atr eq 'checkmin' ) {
$qrec->{'checkmin'} = int( $post->{"pq_${q}_checkmin"} ) || 0;
$qrec->{'checkmax'} = int( $post->{"pq_${q}_checkmax"} ) || 255;
next;
}
# otherwise, let it by.
$qrec->{$atr} = $val;
}
# insert record into poll structure
$poll->{'pq'}->[$q] = $qrec;
my $num_opts = 0;
foreach my $o ( 0 .. $qrec->{'opts'} - 1 ) {
next unless defined $post->{"pq_${q}_opt_$o"};
if ( length( $post->{"pq_${q}_opt_$o"} ) > $RULES{'items'}->{'maxlength'} ) {
$qrec->{'opt'}->[$o] =
substr( $post->{"pq_${q}_opt_$o"}, 0, $RULES{'items'}->{'maxlength'} );
$err->{$q}->{$o}->{'items'} = LJ::Lang::ml("$ml_scope.error.texttoobig");
$num_opts++;
}
elsif ( length( $post->{"pq_${q}_opt_$o"} ) > 0 ) {
# no change necessary
$qrec->{'opt'}->[$o] = $post->{"pq_${q}_opt_$o"};
$num_opts++;
}
}
# too few options specified?
if ( $num_opts < $RULES{'items'}->{'min'} && $qrec->{'type'} =~ /^(drop|check|radio)$/ )
{
$err->{$q}->{'items'} = LJ::Lang::ml("$ml_scope.error.allitemsblank");
}
# checks if minimum and maximum options for checkboxes are OK
if ( $qrec->{type} eq 'check' ) {
my $checkmin = $qrec->{'checkmin'};
if ( $checkmin > $num_opts ) {
$err->{$q}->{'checkmin'} = LJ::Lang::ml("$ml_scope.error.checkmintoohigh2");
}
my $checkmax = $qrec->{'checkmax'};
if ( $checkmax < $checkmin ) {
$err->{$q}->{'checkmax'} = LJ::Lang::ml("$ml_scope.error.checkmaxtoolow2");
}
}
}
# closure to apply action to poll object, given 'type', 'item', and 'val'
my $do_action = sub {
my ( $type, $item, $val ) = @_;
return unless $type && defined $item && defined $val;
# move action
if ( $type eq "move" ) {
# up or down?
my $adj = undef;
if ( $val eq 'up' && $item - 1 >= 0 ) {
$adj = $item - 1;
}
elsif ( $val eq 'dn' && $item + 1 <= $poll->{'count'} ) {
$adj = $item + 1;
}
# invalid action
return unless $adj;
# swap poll items and error references
my $swap = sub { return ( $_[1], $_[0] ) };
( $poll->{'pq'}->[$adj], $poll->{'pq'}->[$item] ) =
$swap->( $poll->{'pq'}->[$adj], $poll->{'pq'}->[$item] );
( $err->{$adj}, $err->{$item} ) =
$swap->( $err->{$adj}, $err->{$item} );
# focus on the new position
$focuson = $adj;
return;
}
# delete action
if ( $type eq "delete" ) {
# delete from poll and decrement question count
splice( @{ $poll->{"pq"} }, $item, 1 );
$poll->{'count'}--;
delete $err->{$item};
# focus on the previous item, unless this one was the top one, in which case we will focus on the new first
$focuson = $item > 0 ? $item - 1 : 0;
return;
}
# request more options
if ( $type eq "request" ) {
# add more items
$poll->{"pq"}->[$item]->{'opts'} += $RULES{'items'}->{'more'};
$poll->{'pq'}->[$item]->{'opts'} = $RULES{'items'}->{'max'}
if @{ $poll->{'pq'} }[$item]->{'opts'} > $RULES{'items'}->{'max'};
# focus on the item we just added more options for
$focuson = $item;
return;
}
# insert
if ( $type eq "insert" ) {
# increase poll count
$poll->{'count'}++;
# splice new item in
splice(
@{ $poll->{'pq'} },
$item, 0,
{
"question" => '',
"type" => $val,
"opts" => ( $val =~ /^(radio|drop|check)$/ )
? $RULES{'items'}->{'start'}
: 0,
"opt" => [],
}
);
# focus on the new item
$focuson = $item;
return;
}
};
# go through the count again, this time apply requested actions
foreach my $q ( 0 .. $poll->{'count'} ) {
# if there is an action, perform the action
foreach my $act (qw(move delete insert request)) {
# images stick an .x and .y on inputs
my $do = $post->{"$act:$q:do.x"} ? "$act:$q:do.x" : "$act:$q:do";
# catches everything but move
if ( $post->{$do} ) {
# catches deletes, requests, etc
if ( $act ne 'insert' ) {
$do_action->( $act, $q, $act );
next;
}
# catches inserts
if ( $post->{"$act:$q"} =~ /^(radio|check|drop|text|scale)$/ ) {
$do_action->( $act, $q, $1 );
next;
}
}
# catches moves
if ( defined $post->{"$act:$q:up.x"} && $post->{"$act:$q:up.x"} =~ /\d+/
|| ( defined $post->{"$act:$q:dn.x"} && $post->{"$act:$q:dn.x"} =~ /\d+/ ) )
{
$do_action->( $act, $q, $post->{"$act:$q:up.x"} ? 'up' : 'dn' );
next;
}
}
}
# all arguments are refs, nothing to return
return $poll;
};
# variables to pass around
my $poll = {};
my $err = {};
# create poll code given a %poll object
my $make_code = sub {
my $poll = shift;
my $ret;
# start out the tag
$ret .=
"<poll name='"
. LJ::ehtml( $poll->{'name'} )
. "' isanon='"
. $poll->{'isanon'}
. "' whovote='"
. LJ::ehtml( $poll->{'whovote'} )
. "' whoview='"
. LJ::ehtml( $poll->{'whoview'} ) . "'>\n";
# go through and make <poll-question> tags
foreach my $q ( 0 .. $poll->{'count'} - 1 ) {
my $elem = $poll->{'pq'}->[$q];
$ret .= "<poll-question type='$elem->{'type'}'";
# fill in attributes
if ( $elem->{'type'} eq 'text' ) {
foreach my $el (qw(size maxlength)) {
$ret .= " $el='" . LJ::ehtml( $elem->{$el} ) . "'";
}
}
elsif ( $elem->{'type'} eq 'scale' ) {
foreach my $el (qw(from to by lowlabel highlabel)) {
$ret .= " $el='" . LJ::ehtml( $elem->{$el} ) . "'";
}
}
elsif ( $elem->{'type'} eq 'check' ) {
foreach my $el (qw(checkmin checkmax)) {
$ret .= " $el='" . LJ::ehtml( $elem->{$el} ) . "'";
}
}
$ret .= ">\n";
$ret .= $elem->{'question'} . "\n" if $elem->{'question'};
if ( $elem->{'type'} =~ /^(radio|drop|check)$/ ) {
# make <poll-item> tags
foreach my $o ( 0 .. $elem->{'opts'} ) {
$ret .= "<poll-item>$elem->{'opt'}->[$o]</poll-item>\n"
if defined $elem->{'opt'}->[$o] && $elem->{'opt'}->[$o] ne '';
}
}
$ret .= "</poll-question>\n";
}
# close off the poll
$ret .= "</poll>";
# escape html on this because it'll currently be sent to user so they can copy/paste
return $ret;
};
# generates html for the hidden elements necessary to maintain
# the state of the given poll
my $poll_hidden = sub {
my $poll = shift;
my @elements = ();
foreach my $k ( keys %$poll ) {
# poll attributes
unless ( ref $poll->{$k} eq 'ARRAY' ) {
push @elements, ( $k, $poll->{$k} );
next;
}
# poll questions
my $q_idx = 0;
foreach my $q ( @{ $poll->{$k} } ) {
# question attributes
foreach my $atr ( keys %$q ) {
unless ( ref $q->{$atr} eq 'ARRAY' ) {
push @elements, ( "${k}_${q_idx}_$atr", $q->{$atr} );
next;
}
# radio/text/drop options
my $opt_idx = 0;
foreach my $o ( @{ $q->{$atr} } ) {
push @elements, ( "${k}_${q_idx}_${atr}_$opt_idx", $o );
$opt_idx++;
}
}
$q_idx++;
}
}
return \@elements;
};
# process post input
if ( $r->did_post() && !$post->{'start_over'} ) {
# load poll hash from $post and get action and error info
$poll = $build_poll->($err);
$vars->{poll} = $poll;
$vars->{err} = $err;
$vars->{poll_hidden} = $poll_hidden;
# generate poll preview for them
if ( ( $post->{'see_preview'} || $post->{'see_code'} ) && !%$err ) {
# generate code for preview
my $code = $make_code->($poll);
# parse code into a fake poll object so we can call "preview" on it
my $err;
my $codecopy = $code; # parse function will eat the code
my $pollobj = ( LJ::Poll->new_from_html( \$codecopy, \$err, {} ) )[0];
return error_ml( "$ml_scope.error.parsing2", { 'err' => $err } ) if $err;
my $update_url =
LJ::BetaFeatures->user_in_beta( $remote => "updatepage" )
? "$LJ::SITEROOT/entry/new"
: "$LJ::SITEROOT/update";
my $usejournal = $getextra ? "?usejournal=$authas" : '';
$vars->{update_url} = $update_url . $usejournal;
$vars->{pollobj} = $pollobj;
$vars->{err} = $err;
$vars->{see_code} = $post->{see_code} ? 1 : 0;
$vars->{code} = $code;
return DW::Template->render_template( 'poll/preview.tt', $vars );
}
}
return DW::Template->render_template( 'poll/create.tt', $vars );
}
1;