#!/usr/bin/perl # # DW::Controller::Inbox # # Pages for exporting journal content. # # Authors: # Ruth Hatch # # Copyright (c) 2015-2020 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::Inbox; use v5.10; use strict; use warnings; use DW::Controller; use DW::Routing; use DW::Template; use DW::FormErrors; use LJ::Hooks; DW::Routing->register_string( '/inbox/new', \&index_handler, app => 1 ); DW::Routing->register_string( '/inbox/new/compose', \&compose_handler, app => 1 ); DW::Routing->register_string( '/inbox/new/markspam', \&markspam_handler, app => 1 ); DW::Routing->register_rpc( 'inbox_actions', \&action_handler, format => 'json' ); my $PAGE_LIMIT = 15; sub index_handler { my ( $ok, $rv ) = controller( form_auth => 1 ); return $rv unless $ok; my $r = $rv->{r}; my $POST = $r->post_args; my $GET = $r->get_args; my $remote = $rv->{remote}; my $vars; my $scope = '/inbox/index.tt'; my $errors = DW::FormErrors->new; return error_ml("$scope.error.not_ready") unless $remote->can_use_esn; my $inbox = $remote->notification_inbox or return error_ml( "$scope.error.couldnt_retrieve_inbox", { user => $remote->{user} } ); # Take a supplied filter but default it to undef unless it is valid my $view = $GET->{view} || $POST->{view} || undef; $view = undef if $view && $view =~ /\W/; $view = undef if $view eq 'archive' && !LJ::is_enabled('esn_archive'); $view = undef if $view && !LJ::NotificationInbox->can("${view}_items"); $view ||= 'all'; my $itemid = $view eq "singleentry" ? int( $POST->{itemid} || $GET->{itemid} || 0 ) : 0; my $expand = $GET->{expand}; my $page = int( $POST->{page} || $GET->{page} || 1 ); if ( $r->did_post ) { my $action; if ( $POST->{mark_read} ) { $action = 'mark_read'; } elsif ( $POST->{mark_unread} ) { $action = 'mark_unread'; } elsif ( $POST->{delete_all} ) { $action = 'delete_all'; } elsif ( $POST->{mark_all} ) { $action = 'mark_all'; } elsif ( $POST->{delete} ) { $action = 'delete'; } my @item_ids; for my $key ( keys %{$POST} ) { next unless $key =~ /check_(\d+)/; push @item_ids, $POST->{$key}; } my $res = handle_post( $remote, $action, $view, $itemid, \@item_ids ); unless ( $res->{success} ) { for my $error ( @{ $res->{errors} } ) { $errors->add( undef, $error ); } } } # Allow bookmarking to work without Javascript # or before JS events are bound if ( $GET->{bookmark_off} && $GET->{bookmark_off} =~ /^\d+$/ ) { $errors->add( undef, "$scope.error.max_bookmarks" ) unless $inbox->add_bookmark( $GET->{bookmark_off} ); } if ( $GET->{bookmark_on} && $GET->{bookmark_on} =~ /^\d+$/ ) { $inbox->remove_bookmark( $GET->{bookmark_on} ); } # Pagination my $viewarg = $view ? "&view=$view" : ""; my $itemidarg = $itemid ? "&itemid=$itemid" : ""; # Filter by view if specified my @all_items = @{ items_by_view( $inbox, $view, $itemid ) }; my $itemcount = scalar @all_items; $vars->{view} = $view; $vars->{itemcount} = $itemcount; # pagination $page = 1 if $page < 1; my $last_page = POSIX::ceil( ( scalar @all_items ) / $PAGE_LIMIT ); $last_page ||= 1; $page = $last_page if $page > $last_page; $vars->{page} = $page; $vars->{last_page} = $last_page; $vars->{item_html} = render_items( $page, $view, $remote, \@all_items, $expand ); $vars->{folder_html} = render_folders( $remote, $view ); # choose button text depending on whether user is viewing all emssages or only a subfolder # to avoid any confusion as to what deleting and marking read will do my $mark_all_text = ""; my $delete_all_text = ""; if ( $view eq "all" ) { $mark_all_text = "widget.inbox.menu.mark_all_read.btn"; $delete_all_text = "widget.inbox.menu.delete_all.btn"; } elsif ( $view eq "singleentry" ) { $mark_all_text = "widget.inbox.menu.mark_all_read.entry.btn"; $delete_all_text = "widget.inbox.menu.delete_all.entry.btn"; $vars->{itemid} = $itemid; } else { $mark_all_text = "widget.inbox.menu.mark_all_read.subfolder.btn"; $delete_all_text = "widget.inbox.menu.delete_all.subfolder.btn"; } $vars->{mark_all} = $mark_all_text; $vars->{delete_all} = $delete_all_text; $vars->{errors} = $errors; # TODO: Remove this when beta is over $vars->{dw_beta} = LJ::load_user('dw_beta'); return DW::Template->render_template( 'inbox/index.tt', $vars ); } sub render_items { my ( $page, $view, $remote, $items_ref, $expand ) = @_; my $inbox = $remote->notification_inbox or return error_ml( "/inbox/index.tt.error.couldnt_retrieve_inbox", { 'user' => $remote->{user} } ); my $starting_index = ( $page - 1 ) * $PAGE_LIMIT; my $ending_index = $starting_index - 1 + $PAGE_LIMIT; my @display_items = @$items_ref; @display_items = sort { $b->when_unixtime <=> $a->when_unixtime } @display_items; @display_items = @display_items[ $starting_index .. $ending_index ]; my @cleaned_items; foreach my $item (@display_items) { last unless $item; my $cleaned = { 'qid' => $item->qid, 'title' => $item->title, 'read' => ( $item->read ? "read" : "unread" ), }; my $bookmark = 'bookmark_' . ( $inbox->is_bookmark( $item->qid ) ? "on" : "off" ); $cleaned->{bookmark_img} = LJ::img( $bookmark, "", { 'class' => 'item_bookmark' } ); $cleaned->{bookmark} = $bookmark; # For clarity, we display both a relative time (e.g. "5 days ago") # and an absolute time (e.g. "2019-05-11 14:34 UTC") in the # notification list. my $event_time = $item->when_unixtime; $cleaned->{rel_time} = LJ::diff_ago_text($event_time); $cleaned->{abs_time} = LJ::S2::sitescheme_secs_to_iso( $event_time, { tz => "UTC" } ); my $contents = $item->as_html || ''; my $expanded = ''; if ($contents) { LJ::ehtml( \$contents ); my $is_expanded = $expand && $expand == $item->qid; $is_expanded ||= $remote->prop('esn_inbox_default_expand'); $is_expanded = 0 if $item->read; $is_expanded = 1 if ( $view eq "usermsg_sent_last" ); $expanded = $is_expanded ? "inbox_expand" : "inbox_collapse"; } $cleaned->{expanded_img} = LJ::img( $expanded, "", { 'class' => 'item_expand' } ); $cleaned->{expanded} = $expanded; $cleaned->{contents} = $contents; push @cleaned_items, $cleaned; } my $vars = { messages => \@cleaned_items, page => $page, view => $view }; return DW::Template->template_string( 'inbox/msg_list.tt', $vars ); } sub render_folders { my ( $remote, $view ) = @_; my $user_messsaging = LJ::is_enabled('user_messaging'); my $inbox = $remote->notification_inbox or return error_ml( "/inbox/index.tt.error.couldnt_retrieve_inbox", { 'user' => $remote->{user} } ); my $vars; my @children = ( { view => 'circle', label => 'circle_updates', unread => $inbox->circle_event_count, children => [ { view => 'birthday', label => 'birthdays' }, { view => 'encircled', label => 'encircled' } ] }, { view => 'entrycomment', label => 'entries_and_comments', unread => $inbox->entrycomment_event_count }, { view => 'pollvote', label => 'poll_votes', unread => $inbox->pollvote_event_count }, { view => 'communitymembership', label => 'community_membership', unread => $inbox->communitymembership_event_count }, { view => 'sitenotices', label => 'site_notices', unread => $inbox->sitenotices_event_count } ); if ($user_messsaging) { # push links for recieved PMs and sent PMs to the beginning and end of the list, respectively unshift @children, { view => 'usermsg_recvd', label => 'messages', unread => $inbox->usermsg_recvd_event_count }; push @children, { view => 'usermsg_sent', label => 'sent', unread => $inbox->usermsg_sent_event_count }; } # put 'unread' at the very top unshift @children, { view => 'unread', label => 'unread', unread => $inbox->all_event_count }; push @children, { view => 'bookmark', label => 'bookmarks', unread => $inbox->bookmark_count }; push @children, { view => 'archived', label => 'archive' } if LJ::is_enabled('esn_archive'); $vars->{folder_links} = { view => 'all', label => 'all', unread => $inbox->all_event_count, children => \@children }; $vars->{user_messaging} = $user_messsaging; $vars->{view} = $view; return DW::Template->template_string( 'inbox/folders.tt', $vars ); } sub action_handler { my ( $ok, $rv ) = controller(); return $rv unless $ok; # gets the request and args my $r = $rv->{r}; my $args = $r->json; my $action = $args->{action}; my $ids = $args->{'ids'}; my $view = $args->{view} || 'all'; my $page = $args->{page} || 1; my $itemid = $args->{itemid} || 0; my $remote = $rv->{remote}; my $form_auth = $args->{lj_form_auth}; my $expand; if ( $action eq 'expand' ) { $expand = $ids; } else { return DW::RPC->err( LJ::Lang::ml('/inbox/index.tt.error.invalidform') ) unless LJ::check_form_auth($form_auth); my $res = handle_post( $remote, $action, $view, $itemid, $ids ); unless ( $res->{success} ) { return DW::RPC->out( errors => $res->{errors} ); } } my $getextra = {}; if ( $view eq 'singleentry' ) { $getextra->{view} = $view; $getextra->{itemid} = $itemid; } elsif ( $view ne 'all' ) { $getextra->{view} = $view; } my $inbox = $remote->notification_inbox; my $folder_html = render_folders( $remote, $view ); # if we've removed items from the view, we need to rebuild the list and recalculate pages if ( $action eq 'delete' || $action eq 'delete_all' ) { my $display_items = items_by_view( $inbox, $view, $itemid ); my $last_page = POSIX::ceil( ( scalar @$display_items ) / $PAGE_LIMIT ); $last_page ||= 1; $page = $last_page if $page > $last_page; my $items_html = render_items( $page, $view, $remote, $display_items, $expand ); my $path = "/inbox/new"; my $pages_html = DW::Template->template_string( 'components/pagination.tt', { current => $page, total_pages => $last_page, path => $path, cur_args => $getextra } ); return DW::RPC->out( success => { items => $items_html, folders => $folder_html, pages => $pages_html, unread_count => $inbox->unread_count } ); } else { return DW::RPC->out( success => { unread_count => $inbox->unread_count, folders => $folder_html, } ); } } sub handle_post { my ( $remote, $action, $view, $itemid, $item_ids ) = @_; my @errors; my $inbox = $remote->notification_inbox or return error_ml( "/inbox/index.tt.error.couldnt_retrieve_inbox", { 'user' => $remote->{user} } ); return { 'errors' => ["No action specified!"] } unless $action; if ( $action eq 'mark_all' ) { $inbox->mark_all_read( $view, itemid => $itemid ); } elsif ( $action eq 'delete_all' ) { $inbox->delete_all( $view, itemid => $itemid ); } elsif ( $action eq 'bookmark_off' ) { push @errors, LJ::Lang::ml("/inbox/index.tt.error.max_bookmarks") unless $inbox->add_bookmark($item_ids); } elsif ( $action eq 'bookmark_on' ) { $inbox->remove_bookmark($item_ids); } else { foreach my $id (@$item_ids) { my $item = LJ::NotificationItem->new( $remote, $id ); next unless $item->valid; if ( $action eq 'mark_read' ) { $item->mark_read; } elsif ( $action eq 'mark_unread' ) { $item->mark_unread; } elsif ( $action eq 'delete' ) { $item->delete; } } } if (@errors) { return { 'errors' => \@errors }; } else { return { 'success' => 1 }; } } sub items_by_view { my ( $inbox, $view, $itemid ) = @_; $itemid ||= 0; my @all_items; if ($view) { if ( $view eq "singleentry" ) { @all_items = $inbox->singleentry_items($itemid); } else { @all_items = eval "\$inbox->${view}_items"; } } else { @all_items = $inbox->all_items; } return \@all_items; } sub compose_handler { my ( $ok, $rv ) = controller( form_auth => 1 ); return $rv unless $ok; # gets the request and args my $r = $rv->{r}; my $POST = $r->post_args; my $GET = $r->get_args; my $remote = $rv->{remote}; my $errors = DW::FormErrors->new; return $r->msg_redirect( LJ::Lang::ml( 'protocol.not_validated', { sitename => $LJ::SITENAMESHORT, siteroot => $LJ::SITEROOT } ), $r->ERROR, "$LJ::SITEROOT/inbox" ) unless LJ::is_enabled('user_messaging'); return $r->msg_redirect( LJ::Lang::ml('.suspended.cannot.send'), $r->ERROR, "$LJ::SITEROOT/inbox" ) if $remote->is_suspended; my $remote_id = $remote->{userid}; my $reply_to; # User replying to my $reply_u; # User replying to my $disabled_to = 0; # disable To field if sending a reply message my $msg_subject = ''; # reply subject my $msg_body = ''; # reply body my $msg_parent = ''; # Hidden msg field containing id of parent message my $msg_limit = $remote->count_usermessage_length; my $subject_limit = 255; my $force = 0; # flag for if user wants to force an empty PM my $scope = '/inbox/compose.tt'; # Submitted message if ( $r->did_post ) { my $mode = $POST->{'mode'}; if ( $mode eq 'send' ) { # test encoding my $msg_subject_text = $POST->{'msg_subject'}; $errors->add( 'msg_subject', "$scope.error.text.encoding.subject" ) unless LJ::text_in($msg_subject_text); my ( $subject_length_b, $subject_length_c ) = LJ::text_length($msg_subject_text); $errors->add( 'msg_subject', "$scope.error.subject.length", { subject_length => LJ::commafy($subject_length_c), subject_limit => LJ::commafy($subject_limit), } ) unless $subject_length_c <= $subject_limit; # test encoding and length my $msg_body_text = $POST->{'msg_body'}; $errors->add( 'msg_body', "$scope.error.text.encoding.text" ) unless LJ::text_in($msg_body_text); my ( $msg_len_b, $msg_len_c ) = LJ::text_length($msg_body_text); $errors->add( 'msg_body', ".error.message.length", { msg_length => LJ::commafy($msg_len_c), msg_limit => LJ::commafy($msg_limit) } ) unless ( $msg_len_c <= $msg_limit ); # checks if the PM is empty (no text) $force = $POST->{'force'}; unless ( $msg_len_c > 0 || $force ) { $errors->add( 'msg_body', '.warning.empty.message' ); $force = 1; } # Get list of recipients my $to_field = $POST->{'msg_to'}; $to_field =~ s/\s//g; # Get recipient list without duplicates my %to_hash = map { lc($_), 1 } split( ",", $to_field ); my @to_list = keys %to_hash; # must be at least one username $errors->add( 'msg_to', "$scope.error.no.username" ) unless ( scalar(@to_list) > 0 ); push @to_list, $remote->username if $POST->{'cc_msg'}; my @msg_list; # persist the default value of the cc_msg option $remote->cc_msg( $POST->{'cc_msg'} ? 1 : 0 ); # Check each user being sent a message foreach my $to (@to_list) { # Check the To field my $tou = LJ::load_user_or_identity($to); unless ($tou) { $errors->add( 'msg_to', "$scope.error.invalid.username", { to => $to } ); next; } # Can only send to other individual users unless ( $tou->is_person || $tou->is_identity || $tou->is_renamed ) { $errors->add( 'msg_to', 'error.message.individual', { ljuser => $tou->ljuser_display } ); next; } # Can't send to unvalidated users unless ( $tou->is_validated || $remote->has_priv( "siteadmin", "*" ) ) { $errors->add( 'msg_to', 'error.message.unvalidated', { ljuser => $tou->ljuser_display } ); next; } # Will target user accept messages from sender unless ( $tou->can_receive_message($remote) ) { errors->add( 'msg_to', 'error.message.canreceive', { ljuser => $tou->ljuser_display } ); next; } my $msguserpic; $msguserpic = $POST->{'prop_picture_keyword'} if ( defined $POST->{'prop_picture_keyword'} ); push @msg_list, LJ::Message->new( { journalid => $remote_id, otherid => $tou->{userid}, subject => $msg_subject_text, body => $msg_body_text, parent_msgid => $POST->{'msg_parent'} || undef, userpic => $msguserpic, } ); } # Check that the rate limit will not be exceeded # This is only necessary if there are multiple recipients if ( scalar(@msg_list) > 1 ) { my $up; $up = LJ::Hooks::run_hook( 'upgrade_message', $remote, 'message' ); $up = "
$up" if ($up); $errors->add( undef, ".error.rate.limit", { up => $up } ) unless LJ::Message::ratecheck_multi( userid => $remote_id, msg_list => \@msg_list ); } # check if any of the messages will throw an error unless ( $errors->exist ) { my @errors; foreach my $msg (@msg_list) { $msg->can_send( \@errors ); } foreach my $error (@errors) { $error->add( undef, $error ); } } # send all the messages and display confirmation unless ( $errors->exist ) { my @errors; foreach my $msg (@msg_list) { $msg->send( \@errors ); } foreach my $error (@errors) { $error->add( undef, $error ); } return $r->msg_redirect( LJ::Lang::ml("$scope.message.sent"), $r->SUCCESS, "$LJ::SITEROOT/inbox" ) unless $errors->exist; } } } # Sending a reply to a message if ( ( $GET->{mode} && $GET->{mode} eq 'reply' ) || $POST->{'msgid'} ) { my $msgid = $GET->{'msgid'} || $POST->{'msgid'}; next unless $msgid; my $msg = LJ::Message->load( { msgid => $msgid, journalid => $remote_id } ); return $r->msg_redirect( LJ::Lang::ml("$scope.error.cannot.reply"), $r->ERROR, "$LJ::SITEROOT/inbox" ) unless $msg->can_reply( $msgid, $remote_id ); $reply_u = $msg->other_u; $reply_to = $reply_u->display_name; $disabled_to = 1; $msg_subject = $msg->subject_raw || "(no subject)"; $msg_subject = "Re: " . $msg_subject unless $msg_subject =~ /Re: /; $msg_body = $msg->body_raw; $msg_body =~ s/(.{70}[^\s]*)\s+/$1\n/g; $msg_body =~ s/(^.*)/\> $1/gm; $msg_body = "\n\n--- $reply_to wrote:\n" . $msg_body; $msg_parent .= LJ::html_hidden( { name => 'msg_parent', value => "$msgid", } ); } # autocomplete To field with trusted and watched people my @flist = (); if ( LJ::isu($remote) ) { my %trusted_and_watched_userids = map { $_ => 1 } ( $remote->trusted_userids, $remote->watched_userids ); my $us = LJ::load_userids( keys %trusted_and_watched_userids ); @flist = map { $us->{$_}->display_name } grep { $us->{$_}->is_personal || $us->{$_}->is_identity } keys %trusted_and_watched_userids; } # Are we sending a copy of the message to the user? my $cc_msg_option = $remote->cc_msg; my $vars = { errors => $errors, formdata => $POST || { msg_to => ( $GET->{'user'} || undef ) }, msg_body => $msg_body, msg_subject => $msg_subject, msg_parent => $msg_parent, reply_u => $reply_u, reply_to => $reply_to, autocomplete => \@flist, cc_msg_option => $cc_msg_option, folder_html => render_folders($remote), commafy => \&LJ::commafy, remote => $remote, msg_limit => $msg_limit }; return DW::Template->render_template( 'inbox/compose.tt', $vars ); } sub markspam_handler { my ( $ok, $rv ) = controller( form_auth => 1 ); return $rv unless $ok; # gets the request and args my $r = $rv->{r}; my $POST = $r->post_args; my $GET = $r->get_args; my $remote = $rv->{remote}; my $errors = DW::FormErrors->new; my $remote_id = $remote->{'userid'}; my $msg_id = $GET->{msgid} || $POST->{msgid}; my $msg = LJ::Message->load( { msgid => $msg_id, journalid => $remote_id } ); return $r->msg_redirect( "Message cannot be loaded.", $r->ERROR, "$LJ::SITEROOT/inbox" ) unless $msg && $msg->valid; return $r->msg_redirect( "You cannot report a message you sent as spam.", $r->ERROR, "$LJ::SITEROOT/inbox" ) if $msg->type eq "out"; return $r->msg_redirect( "You are not allowed to report messages as spam.", $r->ERROR, "$LJ::SITEROOT/inbox" ) if LJ::sysban_check( 'spamreport', $remote->user ); if ( $r->did_post && $POST->{'confirm'} ) { # Some action must be selected $errors->add( undef, 'No action selected' ) unless ( $POST->{spam} || $POST->{'ban'} ); # Mark as spam if ( $POST->{spam} ) { $r->add_msg( "Message marked as spam.", $r->SUCCESS ) if $msg->mark_as_spam; } # Ban user if ( $POST->{'ban'} ) { LJ::set_rel( $remote_id, $msg->otherid, 'B' ); $remote->log_event( 'ban_set', { actiontarget => $msg->otherid, remote => $remote } ); $r->add_msg( "User banned.", $r->SUCCESS ); } return $r->redirect("$LJ::SITEROOT/inbox"); } my $vars = { errors => $errors, msg_user => $msg->other_u, msgid => $msg_id, }; return DW::Template->render_template( 'inbox/markspam.tt', $vars ); } 1;