mourningdove/cgi-bin/LJ/Talk.pm
2026-05-24 01:03:05 +00:00

3315 lines
117 KiB
Perl

# This code was forked from the LiveJournal project owned and operated
# by Live Journal, Inc. The code has been modified and expanded by
# Dreamwidth Studios, LLC. These files were originally licensed under
# the terms of the license supplied by Live Journal, Inc, which can
# currently be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.
package LJ::Talk;
use strict;
use v5.10;
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);
use Digest::MD5;
use MIME::Words;
use MIME::Lite;
use Carp qw/ croak /;
use DW::Task::SphinxCopier;
use DW::Captcha;
use DW::EmailPost::Comment;
use DW::Formats;
use LJ::Utils qw(rand_chars);
use LJ::Comment;
use LJ::Event::JournalNewComment;
use LJ::Event::JournalNewComment::Edited;
use LJ::Global::Constants;
use LJ::JSON;
use LJ::OpenID;
use LJ::S2;
# dataversion for rate limit logging
our $RATE_DATAVER = "1";
my %subjecticons;
# Returns a hashref of the following form:
# {
# types => ['sm', 'md'],
# lists => {
# sm => [
# { img => "sm01_smiley.gif", id => "sm01", w => 15, h => 15, alt => "Smiley" },
# ...
# ],
# md => [
# { img => "md01_alien.gif", id => "md01", w => 32, h => 32, alt => "Smiling Alien" },
# ...
# ]
# },
# pic => { # flat index for convenience
# sm01 => { img => "sm01_smiley.gif", id => "sm01", w => 15, h => 15, alt => "Smiley" },
# ...
# }
# }
sub get_subjecticons {
unless ( keys %subjecticons ) {
$subjecticons{'types'} = [ 'sm', 'md' ];
$subjecticons{'lists'}->{'md'} = [
{ img => "md01_alien.gif", w => 32, h => 32, alt => "Smiling Alien" },
{ img => "md02_skull.gif", w => 32, h => 32, alt => "Skull and Crossbones" },
{ img => "md05_sick.gif", w => 25, h => 25, alt => "Sick Face" },
{ img => "md06_radioactive.gif", w => 20, h => 20, alt => "Radioactive Symbol" },
{ img => "md07_cool.gif", w => 20, h => 20, alt => "Cool Smiley" },
{ img => "md08_bulb.gif", w => 17, h => 23, alt => "Lightbulb" },
{ img => "md09_thumbdown.gif", w => 25, h => 19, alt => "Red Thumbs Down" },
{ img => "md10_thumbup.gif", w => 25, h => 19, alt => "Green Thumbs Up" }
];
$subjecticons{'lists'}->{'sm'} = [
{ img => "sm01_smiley.gif", w => 15, h => 15, alt => "Smiley" },
{ img => "sm02_wink.gif", w => 15, h => 15, alt => "Winking Smiley" },
{ img => "sm03_blush.gif", w => 15, h => 15, alt => "Blushing Smiley" },
{ img => "sm04_shock.gif", w => 15, h => 15, alt => "Shocked Smiley" },
{ img => "sm05_sad.gif", w => 15, h => 15, alt => "Sad Smiley" },
{ img => "sm06_angry.gif", w => 15, h => 15, alt => "Angry Smiley" },
{ img => "sm07_check.gif", w => 15, h => 15, alt => "Checkmark" },
{ img => "sm08_star.gif", w => 20, h => 18, alt => "Gold Star" },
{ img => "sm09_mail.gif", w => 14, h => 10, alt => "Envelope" },
{ img => "sm10_eyes.gif", w => 24, h => 12, alt => "Shifty Eyes" }
];
# assemble ->{'id'} portion of hash. the part of the imagename before the _
foreach ( keys %{ $subjecticons{'lists'} } ) {
foreach my $pic ( @{ $subjecticons{'lists'}->{$_} } ) {
next unless ( $pic->{'img'} =~ /^(\D{2}\d{2})\_.+$/ );
$subjecticons{'pic'}->{$1} = $pic;
$pic->{'id'} = $1;
}
}
$subjecticons{'pic'}->{'none'} = {
img => "none.gif",
id => "none",
w => 15,
h => 15,
alt => "No Subject Icon Selected"
};
}
return \%subjecticons;
}
# Returns talkurl with GET args added (don't pass #anchors to this :-)
sub talkargs {
my $talkurl = shift;
my $args = join( "&", grep { $_ } @_ );
my $sep = '';
$sep = ( $talkurl =~ /\?/ ? "&" : "?" ) if $args;
return "$talkurl$sep$args";
}
# LJ::Talk::get_subjecticon_by_id(id)
# Args: A subjecticon ID string (like 'none' or 'sm09').
# Returns: A subjecticon hashref suitable for LJ::Talk::print_subjecticon, or
# undef if the ID is empty/invalid.
sub get_subjecticon_by_id {
my $id = shift;
my $subjecticons = LJ::Talk::get_subjecticons();
return $subjecticons->{'pic'}->{$id};
}
# LJ::Talk::print_subjecticon(subjecticon_hashref)
# Args: A hashref that represents a subjecticon, and optionally a string of
# extra HTML attributes.
# Returns: An image tag for the requested subjecticon, or an empty string if the
# subjecticon hashref missing.
# Subjecticon hashrefs usually come from get_subjecticons.
sub print_subjecticon { # expects a subjecticon ref
my ( $p, $extra ) = @_;
return '' unless ref $p eq 'HASH';
return
qq{<img src="$LJ::IMGPREFIX/talk/$p->{img}" border="0" width="$p->{w}" height="$p->{h}" alt="$p->{alt}" valign="middle" $extra />};
}
# LJ::Talk::print_subjecticon_by_id(id)
# Args: A subjecticon ID string (like 'none' or 'sm09'), and optionally a string
# of extra HTML attributes.
# Returns: An image tag for the requested subjecticon, or an empty string if the
# ID is empty or invalid.
sub print_subjecticon_by_id {
my ( $id, $extra ) = @_;
return print_subjecticon( get_subjecticon_by_id($id), $extra );
}
sub link_bar {
my $opts = shift;
my ( $u, $up, $remote, $headref, $itemid ) =
map { $opts->{$_} } qw(u up remote headref itemid);
# we want user objects, so make sure they are
( $u, $up, $remote ) = map { LJ::want_user($_) } ( $u, $up, $remote );
my $mlink = sub {
my ( $url, $piccode ) = @_;
return (
"<a href=\"$url\">" . LJ::img( $piccode, "", { 'align' => 'absmiddle' } ) . "</a>" );
};
my $jarg = "journal=$u->{'user'}&";
my $jargent = "journal=$u->{'user'}&amp;";
my $entry = LJ::Entry->new( $u, ditemid => $itemid );
# << Previous
my @linkele;
my $prevlink = LJ::create_url(
"/go",
host => $LJ::DOMAIN_WEB,
viewing_style => 1,
args => {
journal => $u->user,
itemid => $itemid,
dir => "prev",
}
);
push @linkele, $mlink->( $prevlink, "prev_entry" );
$$headref .= "<link href='$prevlink' rel='Previous' />\n";
# memories
if ( LJ::is_enabled('memories') ) {
push @linkele, $mlink->( "$LJ::SITEROOT/tools/memadd?${jargent}itemid=$itemid", "memadd" );
}
# edit entry - if we have a remote, and that person can manage
# the account in question, OR, they posted the entry, and have
# access to the community in question
if (
defined $remote
&& ( $remote->can_manage($u)
|| ( $remote->equals($up) && $up->can_post_to($u) ) )
)
{
push @linkele,
$mlink->( "$LJ::SITEROOT/editjournal?${jargent}itemid=$itemid", "editentry" );
}
# edit tags
if ( LJ::is_enabled('tags') ) {
if ( defined $remote && LJ::Tags::can_add_entry_tags( $remote, $entry ) ) {
push @linkele,
$mlink->( "$LJ::SITEROOT/edittags?${jargent}itemid=$itemid", "edittags" );
}
}
if ( LJ::is_enabled('tellafriend') ) {
push @linkele,
$mlink->( "$LJ::SITEROOT/tools/tellafriend?${jargent}itemid=$itemid", "tellfriend" )
if ( $entry->can_tellafriend($remote) );
}
if ( $remote && $remote->can_use_esn ) {
my $img_key = $remote->has_subscription(
journal => $u,
event => "JournalNewComment",
arg1 => $itemid,
require_active => 1
) ? "track_active" : "track";
push @linkele,
$mlink->( "$LJ::SITEROOT/manage/tracking/entry?${jargent}itemid=$itemid", $img_key );
}
## >>> Next
my $nextlink = LJ::create_url(
"/go",
host => $LJ::DOMAIN_WEB,
viewing_style => 1,
args => {
journal => $u->user,
itemid => $itemid,
dir => "next",
}
);
push @linkele, $mlink->( "$nextlink", "next_entry" );
$$headref .= "<link href='$nextlink' rel='Next' />\n";
my $ret;
if (@linkele) {
$ret =
qq{<div class="action-box"><ul class="nostyle inner"><li>}
. join( "</li><li>", @linkele )
. "</li></ul></div><div class='clear-floats'></div>";
}
return $ret;
}
# <LJFUNC>
# name: LJ::Talk::can_delete
# des: Determines if a user can delete a comment or entry: You can
# delete anything you've posted. You can delete anything posted in something
# you own (i.e. a comment in your journal, a comment to an entry you made in
# a community). You can also delete any item in an account you have the
# "A"dministration edge for.
# args: remote, u, up, userpost
# des-remote: User object we're checking access of. From [func[LJ::get_remote]].
# des-u: Username or object of the account the thing is located in.
# des-up: Username or object of person who owns the parent of the thing. (I.e. the poster
# of the entry a comment is in.)
# des-userpost: Username (<strong>not</strong> object) of person who posted the item.
# returns: Boolean indicating whether remote is allowed to delete the thing
# specified by the other options.
# </LJFUNC>
sub can_delete {
my ( $remote, $u, $up, $userpost ) = @_; # remote, journal, posting user, commenting user
$userpost ||= "";
return 0 unless LJ::isu($remote);
return 1
if $remote->user eq $userpost
|| $remote->user eq ( ref $u ? $u->user : $u )
|| LJ::Talk::can_screen(@_);
return 0;
}
sub can_screen {
my ( $remote, $u, $up, $userpost ) = @_; # remote, journal, posting user, commenting user
return 0 unless LJ::isu($remote);
return 1
if $remote->user eq ( ref $up ? $up->user : $up )
|| $remote->can_manage( ref $u ? $u : LJ::load_user($u) );
return 0;
}
sub can_unscreen {
return LJ::Talk::can_screen(@_);
}
sub can_freeze {
return LJ::Talk::can_screen(@_);
}
sub can_unfreeze {
return LJ::Talk::can_unscreen(@_);
}
# <LJFUNC>
# name: LJ::Talk::screening_level
# des: Determines the screening level of a particular post given the relevant information.
# args: journalu, jitemid
# des-journalu: User object of the journal the post is in.
# des-jitemid: Itemid of the post.
# returns: Single character that indicates the screening level. Undef means don't screen
# anything, 'A' means screen All, 'R' means screen Anonymous (no-remotes), 'F' means
# screen non-friends.
# </LJFUNC>
sub screening_level {
my ( $journalu, $jitemid ) = @_;
die 'LJ::screening_level needs a user object.' unless ref $journalu;
$jitemid += 0;
die 'LJ::screening_level passed invalid jitemid.' unless $jitemid;
# load the logprops for this entry
my %props;
LJ::load_log_props2( $journalu->{userid}, [$jitemid], \%props );
# determine if userprop was overriden
my $val = $props{$jitemid}{opt_screening} || '';
return if $val eq 'N'; # N means None, so return undef
return $val if $val;
# now return userprop, as it's our last chance
my $userprop = $journalu->prop('opt_whoscreened');
return $userprop && $userprop eq 'N' ? undef : $userprop;
}
sub update_commentalter {
my ( $u, $itemid ) = @_;
LJ::set_logprop( $u, $itemid, { 'commentalter' => time() } );
}
# <LJFUNC>
# name: LJ::Talk::get_comments_in_thread
# class: web
# des: Gets a list of comment ids that are contained within a thread, including the
# comment at the top of the thread. You can also limit this to only return comments
# of a certain state.
# args: u, jitemid, jtalkid, onlystate, screenedref
# des-u: user object of user to get comments from
# des-jitemid: journal itemid to get comments from
# des-jtalkid: journal talkid of comment to use as top of tree
# des-onlystate: if specified, return only comments of this state (e.g. A, F, S...)
# des-screenedref: if provided and an array reference, will push on a list of comment
# ids that are being returned and are screened (mostly for use in deletion so you can
# unscreen the comments)
# returns: undef on error, array reference of jtalkids on success
# </LJFUNC>
sub get_comments_in_thread {
my ( $u, $jitemid, $jtalkid, $onlystate, $screened_ref ) = @_;
$u = LJ::want_user($u);
$jitemid += 0;
$jtalkid += 0;
$onlystate = uc $onlystate;
return undef
unless $u
&& $jitemid
&& $jtalkid
&& ( !$onlystate || $onlystate =~ /^\w$/ );
# get all comments to post
my $comments = LJ::Talk::get_talk_data( $u, 'L', $jitemid ) || {};
# see if our comment exists
return undef unless $comments->{$jtalkid};
# create relationship hashref and count screened comments in post
my %parentids;
$parentids{$_} = $comments->{$_}{parenttalkid} foreach keys %$comments;
# now walk and find what to update
my %to_act;
foreach my $id ( keys %$comments ) {
my $act = ( $id == $jtalkid );
my $walk = $id;
while ( $parentids{$walk} ) {
if ( $parentids{$walk} == $jtalkid ) {
# we hit the one we want to act on
$act = 1;
last;
}
last if $parentids{$walk} == $walk;
# no match, so move up a level
$walk = $parentids{$walk};
}
# set it as being acted on
$to_act{$id} = 1 if $act && ( !$onlystate || $comments->{$id}{state} eq $onlystate );
# push it onto the list of screened comments? (if the caller is doing a delete, they need
# a list of screened comments in order to unscreen them)
push @$screened_ref, $id if ref $screened_ref && # if they gave us a ref
$to_act{$id} && # and we're acting on this comment
$comments->{$id}{state} eq 'S'; # and this is a screened comment
}
# return list from %to_act
return [ keys %to_act ];
}
# <LJFUNC>
# name: LJ::Talk::delete_thread
# class: web
# des: Deletes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to delete thread from.
# des-jitemid: Journal itemid of item to delete comments from.
# des-jtalkid: Journal talkid of comment at top of thread to delete.
# returns: 1 on success; undef on error
# </LJFUNC>
sub delete_thread {
my ( $u, $jitemid, $jtalkid ) = @_;
# get comments and delete 'em
my @screened;
my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, undef, \@screened );
LJ::Talk::unscreen_comment( $u, $jitemid, @screened ) if @screened; # if needed only!
my $num = LJ::delete_comments( $u, "L", $jitemid, @$ids );
LJ::replycount_do( $u, $jitemid, "decr", $num );
LJ::Talk::update_commentalter( $u, $jitemid );
return 1;
}
# <LJFUNC>
# name: LJ::Talk::delete_comment
# class: web
# des: Deletes a single comment.
# args: u, jitemid, jtalkid, state?
# des-u: Userid or user object to delete comment from.
# des-jitemid: Journal itemid of item to delete comment from.
# des-jtalkid: Journal talkid of the comment to delete.
# des-state: Optional. If you know it, provide the state
# of the comment being deleted, else we load it.
# returns: 1 on success; undef on error
# </LJFUNC>
sub delete_comment {
my ( $u, $jitemid, $jtalkid, $state ) = @_;
return undef unless $u && $jitemid && $jtalkid;
unless ($state) {
my $td = LJ::Talk::get_talk_data( $u, 'L', $jitemid );
return undef unless $td;
$state = $td->{$jtalkid}->{state};
}
return undef unless $state;
# if it's screened, unscreen it first to properly adjust logprops
LJ::Talk::unscreen_comment( $u, $jitemid, $jtalkid )
if $state eq 'S';
# now do the deletion
my $num = LJ::delete_comments( $u, "L", $jitemid, $jtalkid );
LJ::replycount_do( $u, $jitemid, "decr", $num );
LJ::Talk::update_commentalter( $u, $jitemid );
# done
return 1;
}
# <LJFUNC>
# name: LJ::Talk::freeze_thread
# class: web
# des: Freezes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to freeze thread from.
# des-jitemid: Journal itemid of item to freeze comments from.
# des-jtalkid: Journal talkid of comment at top of thread to freeze.
# returns: 1 on success; undef on error
# </LJFUNC>
sub freeze_thread {
my ( $u, $jitemid, $jtalkid ) = @_;
# now we need to update the states
my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, 'A' );
LJ::Talk::freeze_comments( $u, "L", $jitemid, 0, $ids );
return 1;
}
# <LJFUNC>
# name: LJ::Talk::unfreeze_thread
# class: web
# des: unfreezes an entire thread of comments.
# args: u, jitemid, jtalkid
# des-u: Userid or user object to unfreeze thread from.
# des-jitemid: Journal itemid of item to unfreeze comments from.
# des-jtalkid: Journal talkid of comment at top of thread to unfreeze.
# returns: 1 on success; undef on error
# </LJFUNC>
sub unfreeze_thread {
my ( $u, $jitemid, $jtalkid ) = @_;
# now we need to update the states
my $ids = LJ::Talk::get_comments_in_thread( $u, $jitemid, $jtalkid, 'F' );
LJ::Talk::freeze_comments( $u, "L", $jitemid, 1, $ids );
return 1;
}
# <LJFUNC>
# name: LJ::Talk::freeze_comments
# class: web
# des: Freezes comments. This is the internal helper function called by
# freeze_thread/unfreeze_thread. Use those if you wish to freeze or
# unfreeze a thread. This function just freezes specific comments.
# args: u, nodetype, nodeid, unfreeze, ids
# des-u: Userid or object of user to manipulate comments in.
# des-nodetype: Nodetype of the thing containing the specified ids. Typically "L".
# des-nodeid: Id of the node to manipulate comments from.
# des-unfreeze: If 1, unfreeze instead of freeze.
# des-ids: Array reference containing jtalkids to manipulate.
# returns: 1 on success; undef on error
# </LJFUNC>
sub freeze_comments {
my ( $u, $nodetype, $nodeid, $unfreeze, $ids ) = @_;
$u = LJ::want_user($u);
$nodeid += 0;
$unfreeze = $unfreeze ? 1 : 0;
return undef unless LJ::isu($u) && $nodetype =~ /^\w$/ && $nodeid && @$ids;
# get database and quote things
return undef unless $u->writer;
my $quserid = $u->{userid} + 0;
my $qnodetype = $u->quote($nodetype);
my $qnodeid = $nodeid + 0;
# now perform action
my $in = join( ',', map { $_ + 0 } @$ids );
my $newstate = $unfreeze ? 'A' : 'F';
my $res = $u->talk2_do( $nodetype, $nodeid, undef,
"UPDATE talk2 SET state = '$newstate' "
. "WHERE journalid = $quserid AND nodetype = $qnodetype "
. "AND nodeid = $qnodeid AND jtalkid IN ($in)" );
# invalidate talk2row memcache props
LJ::Talk::invalidate_talk2row_memcache( $u->id, @$ids );
return undef unless $res;
return 1;
}
sub screen_comment {
my $u = shift;
return undef unless LJ::isu($u);
my $itemid = shift(@_) + 0;
my @jtalkids = @_;
my $in = join( ',', map { $_ + 0 } @jtalkids );
return unless $in;
my $userid = $u->{'userid'} + 0;
my $updated = $u->talk2_do( "L", $itemid, undef,
"UPDATE talk2 SET state='S' "
. "WHERE journalid=$userid AND jtalkid IN ($in) "
. "AND nodetype='L' AND nodeid=$itemid "
. "AND state NOT IN ('S','D')" );
return undef unless $updated;
# invalidate talk2row memcache props
LJ::Talk::invalidate_talk2row_memcache( $u->id, @jtalkids );
LJ::MemCache::delete( [ $userid, "activeentries:$userid" ] );
if ( $updated > 0 ) {
LJ::replycount_do( $u, $itemid, "decr", $updated );
LJ::set_logprop( $u, $itemid, { 'hasscreened' => 1 } );
}
LJ::MemCache::delete( [ $userid, "screenedcount:$userid:$itemid" ] );
LJ::Talk::update_commentalter( $u, $itemid );
return;
}
sub unscreen_comment {
my $u = shift;
return undef unless LJ::isu($u);
my $itemid = shift(@_) + 0;
my @jtalkids = @_;
my $in = join( ',', map { $_ + 0 } @jtalkids );
return unless $in;
my $userid = $u->{'userid'} + 0;
my $prop = LJ::get_prop( "log", "hasscreened" );
my $updated = $u->talk2_do( "L", $itemid, undef,
"UPDATE talk2 SET state='A' "
. "WHERE journalid=$userid AND jtalkid IN ($in) "
. "AND nodetype='L' AND nodeid=$itemid "
. "AND state='S'" );
return undef unless $updated;
LJ::Talk::invalidate_talk2row_memcache( $u->id, @jtalkids );
LJ::MemCache::delete( [ $userid, "activeentries:$userid" ] );
# update in-memory singletons so they reflect the new state
foreach my $jtalkid (@jtalkids) {
my $c = LJ::Comment->new( $u, jtalkid => $jtalkid );
$c->{state} = 'A' if $c->{_loaded_row};
}
if ( $updated > 0 ) {
LJ::replycount_do( $u, $itemid, "incr", $updated );
my $dbcm = LJ::get_cluster_master($u);
my $hasscreened = $dbcm->selectrow_array( "SELECT COUNT(*) FROM talk2 "
. "WHERE journalid=$userid AND nodeid=$itemid AND nodetype='L' AND state='S'" );
LJ::set_logprop( $u, $itemid, { 'hasscreened' => 0 } ) unless $hasscreened;
}
LJ::MemCache::delete( [ $userid, "screenedcount:$userid:$itemid" ] );
LJ::Talk::update_commentalter( $u, $itemid );
# fire events so that users who missed the original notification
# (because the comment was screened) get notified now
my @jobs;
foreach my $jtalkid (@jtalkids) {
push @jobs,
LJ::Event::JournalNewComment->new_for_unscreen(
LJ::Comment->new( $u, jtalkid => $jtalkid ) );
}
DW::TaskQueue->dispatch(@jobs) if @jobs;
return;
}
# retrieves data from the talk2 table (but preferably memcache)
# returns a hashref (key -> { 'talkid', 'posterid', 'datepost', 'datepost_unix',
# 'parenttalkid', 'state' } , or undef on failure
sub get_talk_data {
my ( $u, $nodetype, $nodeid ) = @_;
return undef unless LJ::isu($u);
return undef unless $nodetype =~ /^\w$/;
return undef unless $nodeid =~ /^\d+$/;
my $ret = {};
# check for data in memcache
my $DATAVER = "3"; # single character
my $PACK_FORMAT = "NNNNC"; ## $talkid, $parenttalkid, $poster, $time, $state
my $RECORD_SIZE = 17;
my $memkey = [ $u->{'userid'}, "talk2:$u->{'userid'}:$nodetype:$nodeid" ];
my $lockkey = $memkey->[1];
my $packed = LJ::MemCache::get($memkey);
# we check the replycount in memcache, the value we count, and then fix it up
# if it seems necessary.
my $rp_memkey = $nodetype eq "L" ? [ $u->{'userid'}, "rp:$u->{'userid'}:$nodeid" ] : undef;
my $rp_count = $rp_memkey ? LJ::MemCache::get($rp_memkey) : 0;
$rp_count ||=
0; # avoid warnings, FIXME how can LJ::MemCache::get return undef or sg that is not undef?
# hook for tests to count memcache gets
if ($LJ::_T_GET_TALK_DATA_MEMCACHE) {
$LJ::_T_GET_TALK_DATA_MEMCACHE->();
}
my $rp_ourcount = 0;
my $fixup_rp = sub {
return unless $nodetype eq "L";
return if $rp_count == $rp_ourcount;
return unless @LJ::MEMCACHE_SERVERS;
return unless $u->writer;
my $gc = LJ::gearman_client();
if ( $gc && LJ::conf_test( $LJ::FIXUP_USING_GEARMAN, $u ) ) {
$gc->dispatch_background(
"fixup_logitem_replycount",
Storable::nfreeze( [ $u->id, $nodeid ] ),
{
uniq => "-",
}
);
}
else {
LJ::Talk::fixup_logitem_replycount( $u, $nodeid );
}
};
# Save the talkdata on the entry for later
my $set_entry_cache = sub {
return 1 unless $nodetype eq 'L';
my $entry = LJ::Entry->new( $u, jitemid => $nodeid );
$entry->set_talkdata($ret);
};
my $memcache_good = sub {
return
$packed
&& substr( $packed, 0, 1 ) eq $DATAVER
&& length($packed) % $RECORD_SIZE == 1;
};
my $memcache_decode = sub {
my $n = ( length($packed) - 1 ) / $RECORD_SIZE;
for ( my $i = 0 ; $i < $n ; $i++ ) {
my ( $talkid, $par, $poster, $time, $state ) =
unpack( $PACK_FORMAT, substr( $packed, $i * $RECORD_SIZE + 1, $RECORD_SIZE ) );
$state = chr($state);
$ret->{$talkid} = {
talkid => $talkid,
state => $state,
posterid => $poster,
datepost_unix => $time,
datepost => LJ::mysql_time($time), # timezone surely fucked. deprecated.
parenttalkid => $par,
};
# comments are counted if they're 'A'pproved or 'F'rozen
$rp_ourcount++ if $state eq "A" || $state eq "F";
}
$fixup_rp->();
# set cache in LJ::Entry object for this set of comments
$set_entry_cache->();
return $ret;
};
return $memcache_decode->() if $memcache_good->();
my $dbcr = LJ::get_cluster_def_reader($u);
return undef unless $dbcr;
my $lock = $dbcr->selectrow_array( "SELECT GET_LOCK(?,10)", undef, $lockkey );
return undef unless $lock;
# it's quite likely (for a popular post) that the memcache was
# already populated while we were waiting for the lock
$packed = LJ::MemCache::get($memkey);
if ( $memcache_good->() ) {
$dbcr->selectrow_array( "SELECT RELEASE_LOCK(?)", undef, $lockkey );
$memcache_decode->();
return $ret;
}
my $memval = $DATAVER;
my $sth =
$dbcr->prepare( "SELECT t.jtalkid AS 'talkid', t.posterid, "
. "t.datepost, UNIX_TIMESTAMP(t.datepost) as 'datepost_unix', "
. "t.parenttalkid, t.state "
. "FROM talk2 t "
. "WHERE t.journalid=? AND t.nodetype=? AND t.nodeid=?" );
$sth->execute( $u->{'userid'}, $nodetype, $nodeid );
die $dbcr->errstr if $dbcr->err;
while ( my $r = $sth->fetchrow_hashref ) {
$ret->{ $r->{'talkid'} } = $r;
{
# make a new $r-type hash which also contains nodetype and nodeid
# -- they're not in $r because they were known and specified in the query
my %row_arg = %$r;
$row_arg{nodeid} = $nodeid;
$row_arg{nodetype} = $nodetype;
# set talk2row memcache key for this bit of data
LJ::Talk::add_talk2row_memcache( $u->id, $r->{talkid}, \%row_arg );
}
$memval .= pack( $PACK_FORMAT,
$r->{'talkid'}, $r->{'parenttalkid'}, $r->{'posterid'},
$r->{'datepost_unix'}, ord( $r->{'state'} ) );
$rp_ourcount++ if $r->{'state'} eq "A";
}
LJ::MemCache::set( $memkey, $memval );
$dbcr->selectrow_array( "SELECT RELEASE_LOCK(?)", undef, $lockkey );
$fixup_rp->();
# set cache in LJ::Entry object for this set of comments
$set_entry_cache->();
return $ret;
}
sub fixup_logitem_replycount {
my ( $u, $jitemid ) = @_;
# attempt to get a database lock to make sure that nobody else is in this section
# at the same time we are
my $nodetype = "L"; # this is only for logitem comment counts
my $rp_memkey = [ $u->{'userid'}, "rp:$u->{'userid'}:$jitemid" ];
my $rp_count = LJ::MemCache::get($rp_memkey) || 0;
my $fix_key = "rp_fixed:$u->{userid}:$nodetype:$jitemid:$rp_count";
my $db_key = "rp:fix:$u->{userid}:$nodetype:$jitemid";
my $got_lock = $u->selectrow_array( "SELECT GET_LOCK(?, 1)", undef, $db_key );
return unless $got_lock;
# setup an unlock handler
my $unlock = sub {
$u->do( "SELECT RELEASE_LOCK(?)", undef, $db_key );
return undef;
};
# check memcache to see if someone has previously fixed this entry in this journal
# with this reply count
my $was_fixed = LJ::MemCache::get($fix_key);
return $unlock->() if $was_fixed;
# if we're doing innodb, begin a transaction, else lock tables
my $sharedmode = "";
if ( $u->is_innodb ) {
$sharedmode = "LOCK IN SHARE MODE";
$u->begin_work;
}
else {
$u->do("LOCK TABLES log2 WRITE, talk2 READ");
}
# get count and then update. this should be totally safe because we've either
# locked the tables or we're in a transaction.
my $ct = $u->selectrow_array(
"SELECT COUNT(*) FROM talk2 FORCE INDEX (nodetype) WHERE "
. "journalid=? AND nodetype='L' AND nodeid=? "
. "AND state IN ('A','F') $sharedmode",
undef, $u->{'userid'}, $jitemid
);
$u->do( "UPDATE log2 SET replycount=? WHERE journalid=? AND jitemid=?",
undef, int($ct), $u->{'userid'}, $jitemid );
# now, commit or unlock as appropriate
if ( $u->is_innodb ) {
$u->commit;
}
else {
$u->do("UNLOCK TABLES");
}
# mark it as fixed in memcache, so we don't do this again
LJ::MemCache::add( $fix_key, 1, 60 );
$unlock->();
LJ::MemCache::set( $rp_memkey, int($ct) );
}
# LJ::Talk::load_comments($u, $remote, $nodetype, $nodeid, $opts)
#
# nodetype: "L" (for log) ... nothing else has been used
# noteid: the jitemid for log.
# opts keys:
# thread -- jtalkid to thread from ($init->{'thread'} or $GET{'thread'} >> 8)
# page -- $GET{'page'}
# view -- $GET{'view'} (picks page containing view's ditemid)
# flat -- boolean: if set, threading isn't done, and it's just a flat chrono view
# up -- [optional] hashref of user object who posted the thing being replied to
# only used to make things visible which would otherwise be screened?
# filter -- [optional] value of comments getarg (screened|frozen|visible)
# used to hide comments not matching the specified type
# out_error -- set by us if there's an error code:
# nodb: database unavailable
# noposts: no posts to load
# out_pages: number of pages
# out_page: page number being viewed
# out_itemfirst: first comment number on page (1-based, not db numbers)
# out_itemlast: last comment number on page (1-based, not db numbers)
# out_pagesize: size of each page
# out_items: number of total top level items
# out_has_collpased: set by us; 0 if no collapsed messages, 1 if there are
#
# userpicref -- hashref to load userpics into, or undef to
# not load them.
# userref -- hashref to load users into, keyed by userid
# top-only -- boolean; if set, only load the top-level comments
#
# returns:
# array of hashrefs containing keys:
# - talkid (jtalkid)
# - posterid (or zero for anon)
# - userpost (string, or blank if anon)
# - upost ($u object, or undef if anon)
# - datepost (mysql format)
# - parenttalkid (or zero for top-level)
# - parenttalkid_actual (set when the $flat mode is set, in which case parenttalkid is always faked to be 0)
# - state ("A"=approved, "S"=screened, "D"=deleted stub)
# - userpic number
# - picid (if userpicref AND userref were given)
# - subject
# - body
# - props => { propname => value, ... }
# - children => [ hashrefs like these ]
# - _loaded => 1 (if fully loaded, subject & body)
# unknown items will never be _loaded
# - _show => {0|1}, if item is to be ideally shown (0 if deleted, screened, or filtered)
# - showable_children - count of showable children for this comment
# - hidden_child => {0|1}, if this comment is hidden by default
# - hide_children => {0|1}, if this comment has its children hidden
# - echi (explicit comment hierarchy indicator)
sub load_comments {
my ( $u, $remote, $nodetype, $nodeid, $opts ) = @_;
my $n = $u->{'clusterid'};
my $viewall = $opts->{viewall};
my $posts = get_talk_data( $u, $nodetype, $nodeid ); # hashref, talkid -> talk2 row, or undef
unless ($posts) {
$opts->{'out_error'} = "nodb";
return;
}
my %users_to_load; # userid -> 1
my @posts_to_load; # talkid scalars
my %children; # talkid -> [ childenids+ ]
my $uposterid = $opts->{'up'} ? $opts->{'up'}->{'userid'} : 0;
my $post_count = 0;
{
my %showable_children; # $id -> $count
foreach my $post ( sort { $b->{'talkid'} <=> $a->{'talkid'} } values %$posts ) {
# kill the threading in flat mode
if ( $opts->{'flat'} ) {
$post->{'parenttalkid_actual'} = $post->{'parenttalkid'};
$post->{'parenttalkid'} = 0;
}
# see if we should ideally show it or not. even if it's
# zero, we'll still show it if it has any children (but we won't show content)
my $state = $post->{state} || '';
my $should_show = $state eq 'D' ? 0 : 1; # no deleted comments
my $parenttalkid = $post->{parenttalkid};
unless ($viewall) {
# first check to see if a filter has been requested
my $poster = LJ::load_userid( $post->{posterid} );
my %filtermap = (
screened => sub { return $state eq 'S' },
frozen => sub { return $state eq 'F' },
visible => sub {
return 0 if $state eq 'S';
return 0 if $poster && $poster->is_suspended;
# no need to check if deleted, because $should_show does that for us
return 1;
},
);
if ( $should_show && $opts->{filter} && exists $filtermap{ $opts->{filter} } ) {
$should_show = $filtermap{ $opts->{filter} }->();
}
# then check for comment owner/journal owner
$should_show = 0
if $should_show && # short circuit, and check the following conditions
# only if we wanted to show in the first place
# can view if not screened, or if screened and some conditions apply
$state eq "S"
&& !(
$remote
&& (
$remote->userid == $uposterid || # made in remote's journal
$remote->userid == $post->{posterid} || # made by remote
$remote->can_manage($u) || # made in a journal remote manages
(
# remote authored the parent, and this comment is by an admin
exists $posts->{$parenttalkid}
&& $posts->{$parenttalkid}->{posterid}
&& $posts->{$parenttalkid}->{posterid} == $remote->userid
&& $poster
&& $poster->can_manage($u)
)
)
);
}
$post->{'_show'} = $should_show;
$post_count += $should_show;
# make any post top-level if it says it has a parent but it isn't
# loaded yet which means either a) row in database is gone, or b)
# somebody maliciously/accidentally made their parent be a future
# post, which could result in an infinite loop, which we don't want.
$post->{'parenttalkid'} = 0
if $post->{'parenttalkid'} && !$posts->{ $post->{'parenttalkid'} };
$post->{'children'} =
[ map { $posts->{$_} } @{ $children{ $post->{'talkid'} } || [] } ];
# increment the parent post's number of showable children,
# which is our showability plus all those of our children
# which were already computed, since we're working new to old
# and children are always newer.
# then, if we or our children are showable, add us to the child list
my $sum = $should_show + ( $showable_children{ $post->{talkid} } || 0 );
if ($sum) {
$showable_children{ $post->{'parenttalkid'} } += $sum;
unshift @{ $children{ $post->{'parenttalkid'} } }, $post->{'talkid'};
# record the # of showable children for each comment (though
# not for the post itself (0))
if ( $post->{parenttalkid} ) {
$posts->{ $post->{parenttalkid} }->{'showable_children'} =
$showable_children{ $post->{'parenttalkid'} };
}
}
}
# explicit comment hierarchy indicator generation
my $echi_display = '';
$echi_display = $remote->prop("opt_echi_display") || '' if $remote;
if ( !$opts->{flat} && $echi_display eq "Y" ) {
my @alpha = ( "a" .. "z" );
# all echi values are initially stored as numeric values; this
# translates from the number to a..z, a[a..z]..z[a..z], etc.
my $to_alpha = sub {
my $num = shift;
# this is 0-based, while the count is 1-based.
$num--;
my $retval = "";
# prepend a third letter only if we have more than 702
# comments (26^2 = 676, plus the initial 26 which don't
# have a second letter = 702)
if ( $num >= 702 ) {
$retval .= $alpha[ ( $num - 702 ) / 676 ];
}
if ( $num >= 26 ) {
$retval .= $alpha[ ( ( $num - 26 ) / 26 ) % 26 ];
}
$retval .= $alpha[ $num % 26 ];
return $retval;
};
my $top_counter = 1;
foreach my $post ( sort { $a->{'talkid'} <=> $b->{'talkid'} } values %$posts ) {
next unless $post->{_show} || $post->{showable_children};
# set the echi for this comment
my $parentid = $post->{'parenttalkid'} || $post->{'parenttalkid_actual'} || 0;
if ( $parentid && $posts->{$parentid} ) {
my $parent = $posts->{$parentid};
$post->{'echi_count'} = 0;
if ( !$parent->{'echi_count'} ) {
$parent->{'echi_count'} = 1;
}
else {
$parent->{'echi_count'} = $parent->{'echi_count'} + 1;
}
if ( !$parent->{'echi_type'} ) {
$parent->{'echi_type'} = 'N';
}
if ( $parent->{'echi_type'} eq 'N' ) {
$post->{'echi_type'} = 'A';
$post->{echi} = $parent->{echi} . $to_alpha->( $parent->{'echi_count'} );
}
else {
$post->{'echi_type'} = 'N';
$post->{echi} = $parent->{echi} . $parent->{'echi_count'};
}
}
else {
$post->{echi} = $top_counter++;
$post->{'echi_count'} = 0;
$post->{'echi_type'} = 'N';
}
# Count number of non-whitespace characters in echi
my $char_count = $post->{echi} =~ tr/a-zA-Z0-9//;
# Add a whitespace every ten non-whitespace characters
$post->{echi} = $post->{echi} . ' ' if ( $char_count % 10 == 0 );
}
}
}
# with a wrong thread number, silently default to the whole page
my $thread = $opts->{'thread'} + 0;
$thread = 0 unless $posts->{$thread};
unless ( $thread || $children{$thread} ) {
$opts->{'out_error'} = "noposts";
return;
}
my $page_size = $LJ::TALK_PAGE_SIZE || 25;
my $max_subjects = $LJ::TALK_MAX_SUBJECTS || 200;
my $threading_point = $LJ::TALK_THREAD_POINT || 50;
# we let the page size initially get bigger than normal for awhile,
# but if it passes threading_point, then everything's in page_size
# chunks:
$page_size = $threading_point if $post_count < $threading_point;
my $top_replies = $thread ? 1 : scalar( @{ $children{$thread} } );
my $pages = int( $top_replies / $page_size );
if ( $top_replies % $page_size ) { $pages++; }
my @top_replies = $thread ? ($thread) : @{ $children{$thread} };
my $page_from_view = 0;
if ( $opts->{'view'} && !$opts->{'page'} ) {
# find top-level comment that this comment is under
my $viewid = $opts->{'view'} >> 8;
while ( $posts->{$viewid} && $posts->{$viewid}->{'parenttalkid'} ) {
$viewid = $posts->{$viewid}->{'parenttalkid'};
}
for ( my $ti = 0 ; $ti < @top_replies ; ++$ti ) {
if ( $posts->{ $top_replies[$ti] }->{'talkid'} == $viewid ) {
$page_from_view = int( $ti / $page_size ) + 1;
last;
}
}
}
my $page = int( $opts->{page} || 0 ) || $page_from_view || 1;
$page = $page < 1 ? 1 : $page > $pages ? $pages : $page;
my $itemfirst = $page_size * ( $page - 1 ) + 1;
my $itemlast = $page == $pages ? $top_replies : ( $page_size * $page );
@top_replies = @top_replies[ $itemfirst - 1 .. $itemlast - 1 ];
push @posts_to_load, @top_replies;
# mark child posts of the top-level to load, deeper
# and deeper until we've hit the page size. if too many loaded,
# just mark that we'll load the subjects;
my @check_for_children = @posts_to_load;
## expand first reply to top-level comments
## %expand_children - list of comments, children of which are to expand
my %expand_children;
unless ( $opts->{'top-only'} ) {
## expand first reply to top-level comments
## %expand_children - list of comments, children of which are to expand
%expand_children = map { $_ => 1 } @top_replies;
}
my ( @subjects_to_load, @subjects_ignored );
# track if there are any collapsed messages being displayed
my $has_collapsed = 0;
while (@check_for_children) {
my $cfc = shift @check_for_children;
next unless defined $children{$cfc};
foreach my $child ( @{ $children{$cfc} } ) {
if ( !$opts->{'top-only'}
&& ( @posts_to_load < $page_size || $expand_children{$cfc} || $opts->{expand_all} )
)
{
push @posts_to_load, $child;
## expand only the first child, then clear the flag
delete $expand_children{$cfc};
}
else {
$has_collapsed = 1;
if ( $opts->{'top-only'} ) {
$posts->{$child}->{'hidden_child'} = 1;
}
if ( @subjects_to_load < $max_subjects ) {
push @subjects_to_load, $child;
}
else {
push @subjects_ignored, $child;
}
}
push @check_for_children, $child;
}
}
$opts->{'out_pages'} = $pages;
$opts->{'out_page'} = $page;
$opts->{'out_itemfirst'} = $itemfirst;
$opts->{'out_itemlast'} = $itemlast;
$opts->{'out_pagesize'} = $page_size;
$opts->{'out_items'} = $top_replies;
$opts->{'out_has_collapsed'} = $has_collapsed;
# load text of posts
my ( $posts_loaded, $subjects_loaded );
$posts_loaded = LJ::get_talktext2( $u, @posts_to_load );
$subjects_loaded = LJ::get_talktext2( $u, { 'onlysubjects' => 1 }, @subjects_to_load )
if @subjects_to_load;
# preload props
my @ids_to_preload = @posts_to_load;
push @ids_to_preload, @subjects_to_load;
my @to_preload = ();
foreach my $jtalkid (@ids_to_preload) {
push @to_preload, LJ::Comment->new( $u, jtalkid => $jtalkid );
}
LJ::Comment->preload_props( $u, @to_preload );
foreach my $talkid (@posts_to_load) {
if ( $opts->{'top-only'} ) {
$posts->{$talkid}->{'hide_children'} = 1;
}
next unless $posts->{$talkid}->{'_show'};
$posts->{$talkid}->{'_loaded'} = 1;
$posts->{$talkid}->{'subject'} = $posts_loaded->{$talkid}->[0];
$posts->{$talkid}->{'body'} = $posts_loaded->{$talkid}->[1];
$users_to_load{ $posts->{$talkid}->{'posterid'} } = 1;
if ( $opts->{'top-only'} ) {
$posts->{$talkid}->{'hide_children'} = 1;
}
}
foreach my $talkid (@subjects_to_load) {
next unless $posts->{$talkid}->{'_show'};
$posts->{$talkid}->{'subject'} = $subjects_loaded->{$talkid}->[0];
$users_to_load{ $posts->{$talkid}->{'posterid'} } ||= 0.5; # only care about username
}
foreach my $talkid (@subjects_ignored) {
next unless $posts->{$talkid}->{'_show'};
$posts->{$talkid}->{'subject'} = "...";
$users_to_load{ $posts->{$talkid}->{'posterid'} } ||= 0.5; # only care about username
}
# load meta-data
{
my %props;
LJ::load_talk_props2( $u->{'userid'}, \@posts_to_load, \%props );
foreach ( keys %props ) {
next unless $posts->{$_}->{'_show'};
$posts->{$_}->{'props'} = $props{$_};
}
}
foreach (@posts_to_load) {
if ( $posts->{$_}->{'props'}->{'unknown8bit'} ) {
LJ::item_toutf8( $u, \$posts->{$_}->{'subject'}, \$posts->{$_}->{'body'}, {} );
}
}
# load users who posted
delete $users_to_load{0};
my %up = ();
if (%users_to_load) {
LJ::load_userids_multiple( [ map { $_, \$up{$_} } keys %users_to_load ] );
# fill in the 'userpost' member on each post being shown
while ( my ( $id, $post ) = each %$posts ) {
my $up = $up{ $post->{'posterid'} };
next unless $up;
$post->{'upost'} = $up;
$post->{'userpost'} = $up->{'user'};
}
}
# optionally give them back user refs
if ( ref( $opts->{userref} ) eq "HASH" ) {
my %userpics = ();
# copy into their ref the users we've already loaded above.
while ( my ( $k, $v ) = each %up ) {
$opts->{userref}->{$k} = $v;
}
# optionally load userpics
if ( ref( $opts->{userpicref} ) eq "HASH" ) {
my @load_pic;
foreach my $talkid (@posts_to_load) {
my $post = $posts->{$talkid};
my $pu = $opts->{userref}->{ $post->{posterid} };
my ( $id, $kw );
if ( $pu && $pu->userpic_have_mapid ) {
my $mapid;
if ( $post->{props} && $post->{props}->{picture_mapid} ) {
$mapid = $post->{props}->{picture_mapid};
}
$kw = $pu ? $pu->get_keyword_from_mapid($mapid) : undef;
$id = $pu ? $pu->get_picid_from_mapid($mapid) : undef;
}
else {
if ( $post->{props} && $post->{props}->{picture_keyword} ) {
$kw = $post->{props}->{picture_keyword};
}
$id = $pu ? $pu->get_picid_from_keyword($kw) : undef;
}
$post->{picid} = $id;
$post->{pickw} = $kw;
push @load_pic, [ $pu, $id ]
if defined $id;
}
load_userpics( $opts->{userpicref}, \@load_pic );
}
}
# make singletons for the returned comments
my $make_comment_singleton = sub {
my ( $self, $jtalkid, $row ) = @_;
return 1 unless $nodetype eq 'L';
# at this point we have data for this comment loaded in memory
# -- instantiate an LJ::Comment object as a singleton and absorb
# that data into the object
my $comment = LJ::Comment->new( $u, jtalkid => $jtalkid );
# add important info to row
$row->{nodetype} = $nodetype;
$row->{nodeid} = $nodeid;
$comment->absorb_row(%$row);
$comment->{childids} = $row->{children};
$comment->{_loaded_childids} = 1;
if ( $row->{children} && scalar @{ $row->{children} } ) {
foreach my $child ( @{ $row->{children} } ) {
$self->( $self, $child, $posts->{$child} );
}
}
return 1;
};
foreach my $talkid (@top_replies) {
$make_comment_singleton->( $make_comment_singleton, $talkid, $posts->{$talkid} );
}
return map { $posts->{$_} } @top_replies;
}
# <LJFUNC>
# name: LJ::Talk::load_userpics
# des: Loads a bunch of userpics at once.
# args: dbarg?, upics, idlist
# des-upics: hashref to load pictures into, keys being the picids.
# des-idlist: [$u, $picid] or [[$u, $picid], [$u, $picid], +] objects
# also supports deprecated old method, of an array ref of picids.
# </LJFUNC>
sub load_userpics {
my ( $upics, $idlist ) = @_;
return undef unless ref $idlist eq 'ARRAY' && $idlist->[0];
# $idlist needs to be an arrayref of arrayrefs,
# HOWEVER, there's a special case where it can be
# an arrayref of 2 items: $u (which is really an arrayref)
# as well due to 'fields' and picid which is an integer.
#
# [$u, $picid] needs to map to [[$u, $picid]] while allowing
# [[$u1, $picid1], [$u2, $picid2], [etc...]] to work.
if ( scalar @$idlist == 2 && !ref $idlist->[1] ) {
$idlist = [$idlist];
}
my @load_list;
foreach my $row ( @{$idlist} ) {
my ( $u, $id ) = @$row;
next unless ref $u && defined $id;
if ( $LJ::CACHE_USERPIC{$id} ) {
$upics->{$id} = $LJ::CACHE_USERPIC{$id};
}
elsif ( $id + 0 ) {
push @load_list, [ $u, $id + 0 ];
}
}
return unless @load_list;
if (@LJ::MEMCACHE_SERVERS) {
my @mem_keys = map { [ $_->[1], "userpic.$_->[1]" ] } @load_list;
my $mem = LJ::MemCache::get_multi(@mem_keys) || {};
while ( my ( $k, $v ) = each %$mem ) {
next unless $v && $k =~ /(\d+)/;
my $id = $1;
$upics->{$id} = LJ::MemCache::array_to_hash( "userpic", $v );
}
@load_list = grep { !$upics->{ $_->[1] } } @load_list;
return unless @load_list;
}
my %db_load;
foreach my $row (@load_list) {
# ignore users on clusterid 0
next unless $row->[0]->{clusterid};
push @{ $db_load{ $row->[0]->{clusterid} } }, $row;
}
foreach my $cid ( keys %db_load ) {
my $dbcr = LJ::get_cluster_def_reader($cid);
unless ($dbcr) {
print STDERR "Error: LJ::Talk::load_userpics unable to get handle; cid = $cid\n";
next;
}
my ( @bindings, @data );
foreach my $row ( @{ $db_load{$cid} } ) {
push @bindings, "(userid=? AND picid=?)";
push @data, ( $row->[0]->{userid}, $row->[1] );
}
next unless @data && @bindings;
my $sth =
$dbcr->prepare( "SELECT userid, picid, width, height, fmt, state, "
. " UNIX_TIMESTAMP(picdate) AS 'picdate', location, flags "
. "FROM userpic2 WHERE "
. join( ' OR ', @bindings ) );
$sth->execute(@data);
while ( my $ur = $sth->fetchrow_hashref ) {
my $id = delete $ur->{'picid'};
$upics->{$id} = $ur;
# force into numeric context so they'll be smaller in memcache:
foreach my $k (qw(userid width height flags picdate)) {
$ur->{$k} += 0;
}
$ur->{location} = uc( substr( $ur->{location} || '', 0, 1 ) );
$LJ::CACHE_USERPIC{$id} = $ur;
LJ::MemCache::set( [ $id, "userpic.$id" ],
LJ::MemCache::hash_to_array( "userpic", $ur ) );
}
}
}
sub talkform {
# Takes a hashref with the following keys / values:
# journalu: required journal user object
# parpost: parent comment hashref. Only keys we use are state and subject.
# replyto: jtalkid of the parent comment (or 0 if replying to entry)
# ditemid: target entry's ditemid
# styleopts: the style options (`?style=light`) at reply time, as a hashref
# thread: thread being viewed at reply time (`?thread=12345`), as a dtalkid
# form: optional full form hashref. Empty if reply page was opened via
# direct link instead of partial form submission.
# do_captcha: optional toggle for creating a captcha challenge
# errors: optional arrayref of errors to display, so user can fix
# Refresher course on IDs:
# Entries and comments have real and "display" ids. This is for reader comfort, not for security.
# "anum" is a random but permanent number attached to entries.
# entry->jitemid * 256 + entry->anum = entry->ditemid
# (usually "itemid" means a jitemid, but reply forms pass a ditemid in their "itemid" field.)
# comment->jtalkid * 256 + comment->entry->anum = comment->dtalkid
# parenttalkid/replyto is always a jtalkid. Sometimes you'll see "dtid" to mean dtalkid.
my $opts = shift;
return "Invalid talkform values." unless ref $opts eq 'HASH';
my ( $journalu, $parpost, $form ) =
map { $opts->{$_} } qw(journalu parpost form);
my $remote = LJ::get_remote();
my $editid = $form->{editid} || 0;
my $comment;
if ($editid) {
$comment = LJ::Comment->new( $journalu, dtalkid => $editid );
return "Cannot load comment information." unless $comment;
}
# A few early exit conditions, before we bother with all this other work:
# make sure journal isn't locked
return
"Sorry, this journal is locked and comments cannot be posted to it or edited at this time."
if $journalu->is_locked;
# check max comments (if posting a new comment; edits are ok.)
unless ($editid) {
my $jitemid = $opts->{'ditemid'} >> 8;
return "Sorry, this entry already has the maximum number of comments allowed."
if LJ::Talk::Post::over_maxcomments( $journalu, $jitemid );
}
my $subjecticons = LJ::Talk::get_subjecticons();
my @subjecticon_ids = ('none');
foreach my $sublist ( $subjecticons{lists}->{sm}, $subjecticons{lists}->{md} ) {
push( @subjecticon_ids, map { $_->{id} } @$sublist );
}
my $entry = LJ::Entry->new( $journalu, ditemid => $opts->{ditemid} );
my $basesubject = $form->{subject} || "";
if ( !$editid && $opts->{replyto} && !$basesubject && $parpost->{'subject'} ) {
$basesubject = $parpost->{'subject'};
$basesubject =~ s/^Re:\s*//i;
$basesubject = "Re: $basesubject";
}
# hashref with "selected" and "items" keys
my $editors = DW::Formats::select_items(
current => $form->{prop_editor},
preferred => $remote ? $remote->prop('comment_editor') : '',
);
my $screening = LJ::Talk::screening_level( $journalu, $opts->{ditemid} >> 8 ) // '';
# pre-calculate some abilities and add them to $remote, so we don't have to do it
# in the template
my $remote_opts;
if ($remote) {
$remote_opts->{can_manage_community} =
$journalu->is_community
&& $remote
&& $remote->can_manage($journalu);
$remote_opts->{can_unscreen_parent} =
( $parpost->{state}
&& $parpost->{state} eq "S"
&& LJ::Talk::can_unscreen( $remote, $journalu, $entry->poster ) );
$remote_opts->{allowed} = !$journalu->does_not_allow_comments_from($remote);
$remote_opts->{banned} = $journalu->has_banned($remote);
$remote_opts->{screened} =
( $journalu->has_autoscreen($remote)
|| $screening eq 'A'
|| ( $screening eq 'R' && !$remote->is_validated )
|| ( $screening eq 'F' && !$journalu->trusts($remote) ) );
}
# Variables for talkform.tt (most of them, at least)
my $template_args = {
hidden_form_elements => '',
form_url => LJ::create_url( '/talkpost_do', host => $LJ::DOMAIN_WEB ),
errors => $opts->{errors},
create_link => '',
subjecticon_ids => \@subjecticon_ids,
editors => $editors,
foundation_beta => !LJ::BetaFeatures->user_in_beta( $remote => "nos2foundation" ),
public_entry => $entry->security eq 'public',
default_usertype => 'user',
comment => {
editid => $editid,
editreason => $form->{editreason} // ( $comment ? $comment->edit_reason : '' ),
oidurl => $form->{oidurl},
oiddo_login => $form->{oiddo_login},
user => $form->{userpost},
password => $form->{password},
do_login => $form->{do_login},
body => $form->{body},
subject => $basesubject,
subjecticon => $form->{subjecticon}
|| 'none', # a subjecticon ID
preformatted => $form->{prop_opt_preformatted},
admin_post => $form->{prop_admin_post},
current_icon_kw => $form->{prop_picture_keyword},
current_icon => LJ::Userpic->new_from_keyword( $remote, $form->{prop_picture_keyword} ),
},
captcha => $opts->{do_captcha}
? {
type => $journalu->captcha_type,
html => DW::Captcha->new( undef, want => $journalu->captcha_type )->print,
}
: 0,
remote => $remote ? $remote : 0,
remote_opts => $remote_opts,
journal => {
user => $journalu->{user},
is_iplogging => $journalu->opt_logcommentips eq 'A' ? 'all'
: $journalu->opt_logcommentips eq 'S' ? 'anon'
: 0,
is_linkstripped => !$remote
|| ( $remote && $remote->is_identity && !$journalu->trusts_or_has_member($remote) ),
is_community => $journalu->is_community,
screens_anon => $screening,
screens_non_access => $screening eq 'F' || $screening eq 'A',
screens_all => $screening eq 'A',
allows_anon => $journalu->{opt_whocanreply} eq "all",
allows_non_access => $journalu->{opt_whocanreply} eq "all"
|| $journalu->{opt_whocanreply} eq "reg",
},
help_icon => sub { LJ::help_icon_html(@_) },
print_subjecticon_by_id => sub { return LJ::Talk::print_subjecticon_by_id(@_) },
};
# Now, we munge some of the more complex template inputs that can make
# a mess inline.
# default_usertype is the initial selected item in the "from" options.
# It defaults to 'user' above, but something else might be better.
# Reminder: allowed usertypes are:
# anonymous
# openid
# openid_cookie (logged-in)
# cookieuser (logged-in)
# user (w/ name provided in "userpost")
if ( $form->{usertype} ) {
# Partial form was submitted, and they already told us who they want to
# be! Pick up where they left off.
$template_args->{default_usertype} = $form->{usertype};
# But there are two exceptions to that. First, quick-reply doesn't know
# about openid_cookie and always says cookieuser, so if they're
# logged in as OpenID, straighten that out.
if ( $form->{usertype} eq 'cookieuser'
&& $remote
&& $remote->is_identity )
{
$template_args->{default_usertype} = 'openid_cookie';
}
# Second, if the logged-in user isn't allowed to comment, it's
# impossible to select "current user." Revert to the default.
if (
(
$template_args->{default_usertype} eq 'cookieuser'
|| $template_args->{default_usertype} eq 'openid_cookie'
)
&& $remote
&& !$template_args->{remote_opts}->{allowed}
)
{
$template_args->{default_usertype} = 'user';
}
}
elsif ( $remote && $template_args->{remote_opts}->{allowed} ) {
# Whole point of logging in is to be the default user, so, yeah.
if ( $remote->is_identity ) {
$template_args->{default_usertype} = 'openid_cookie';
}
else {
$template_args->{default_usertype} = 'cookieuser';
}
}
elsif ( $journalu->{'opt_whocanreply'} eq "all" ) {
$template_args->{default_usertype} = 'anonymous';
}
my $styleopts = $opts->{styleopts} || LJ::viewing_style_opts(%$form);
# hidden values
$template_args->{'hidden_form_elements'} .= LJ::html_hidden(
replyto => $opts->{replyto},
parenttalkid => ( $opts->{replyto} + 0 ),
itemid => $opts->{ditemid},
journal => $journalu->{user},
editid => $editid,
viewing_thread => $opts->{form}->{viewing_thread} || $opts->{thread} || 0,
chrp1 => generate_chrp1( $journalu->{userid}, $opts->{ditemid} ),
%$styleopts,
);
# special link to create an account
if ( !$remote || $remote->openid_identity ) {
$template_args->{'create_link'} =
LJ::Hooks::run_hook( "override_create_link_on_talkpost_form", $journalu ) || '';
}
return DW::Template->template_string( 'journal/talkform.tt', $template_args );
}
# Generate anti-spam challenge/response value
sub generate_chrp1 {
my ( $journal_userid, $ditemid ) = @_;
my ( $time, $secret ) = LJ::get_secret();
my $rchars = LJ::rand_chars(20);
my $chal = $ditemid . "-$journal_userid-$time-$rchars";
my $res = Digest::MD5::md5_hex( $secret . $chal );
return "$chal-$res";
}
# validate the challenge/response value (anti-spammer)
# This is distinct from the outdated challenge/response login method, and
# doesn't require any client-side md5 horseplay; it's just an expiring
# server-provided token that's impractical to forge.
# Returns (1, undef) if valid, (0, errorstring) if not.
sub validate_chrp1 {
my ($chrp) = @_;
my $fail = sub { return ( 0, $_[0] ); };
my $ok = sub { return ( 1, undef ); };
if ( !$chrp ) {
$fail->("missing");
}
my ( $c_ditemid, $c_uid, $c_time, $c_chars, $c_res ) =
split( /\-/, $chrp );
my $chal = "$c_ditemid-$c_uid-$c_time-$c_chars";
my $secret = LJ::get_secret($c_time);
my $res = Digest::MD5::md5_hex( $secret . $chal );
if ( $res ne $c_res ) {
$fail->("invalid");
}
elsif ( $c_time < time() - 2 * 60 * 60 ) {
$fail->("too_old") if $LJ::REQUIRE_TALKHASH_NOTOLD;
}
$ok->();
}
sub icon_dropdown {
my ( $remote, $selected ) = @_;
$selected ||= "";
my %res;
if ($remote) {
LJ::do_request(
{
mode => "login",
ver => $LJ::PROTOCOL_VER,
user => $remote->{'user'},
getpickws => 1,
},
\%res,
{ "noauth" => 1, "userid" => $remote->{'userid'} }
);
}
my $ret = "";
if ( $res{pickw_count} ) {
$ret .= LJ::Lang::ml('/journal/talkform.tt.label.picturetouse2') . " ";
my @pics;
foreach my $i ( 1 ... $res{pickw_count} ) {
push @pics, $res{"pickw_$i"};
}
@pics = sort { lc($a) cmp lc($b) } @pics;
$ret .= LJ::html_select(
{
name => 'prop_picture_keyword',
selected => $selected,
id => 'prop_picture_keyword'
},
( "", LJ::Lang::ml('entryform.opt.defpic'), map { ( $_, $_ ) } @pics )
);
# userpic browse button
if ( $remote && $remote->can_use_userpic_select ) {
my $metatext = $remote->iconbrowser_metatext ? "true" : "false";
my $smallicons = $remote->iconbrowser_smallicons ? "true" : "false";
$ret .=
qq{<input type="button" id="lj_userpicselect" value="Browse" data-iconbrowser-metatext="$metatext" data-iconbrowser-smallicons="$smallicons"/>};
}
# random icon button - hidden for non-JS
$ret .= "<input type='button' class='ljhidden' id='randomicon' value='"
. LJ::Lang::ml('/journal/talkform.tt.userpic.random2') . "'/>";
$ret .= LJ::help_icon_html( "userpics", " " );
}
return $ret;
}
# load the javascript libraries for the icon browser
# args: none
# returns: nothing, just calls need_res
sub init_iconbrowser_js {
# There are three separate implementations of the icon browser.
# The New-New Icon Browser: Depends on Foundation CSS/JS (either site skin
# or minimal version). Used on: Create entry page (if in "updatepage" beta),
# quickreply and talkform on journal pages and on talkpost_do.
LJ::need_res(
{ group => 'foundation' },
'js/foundation/foundation/foundation.js',
'js/foundation/foundation/foundation.reveal.js',
'js/components/jquery.icon-browser.js',
'stc/css/components/icon-browser.css',
);
# The Old-New Icon Browser: Depends on jQuery; only used if NOT in the
# "s2foundation" beta. Used on: Quick-reply and talkform on journal pages,
# talkform on talkpost_do for errors/previews .
LJ::need_res(
{ group => 'jquery' },
# base libraries
'js/jquery/jquery.ui.core.js',
'js/jquery/jquery.ui.widget.js',
'stc/jquery/jquery.ui.core.css',
# for the formatting of the icon selector popup
'js/jquery/jquery.ui.dialog.js',
'stc/jquery/jquery.ui.dialog.css',
# logic for the icon selector
'js/jquery.iconselector.js',
'stc/jquery.iconselector.css',
);
# The Old-Old Icon Browser: Weird ancient technology. Not the exciting kind.
# Used on: New entry page (if NOT in "updatepage" beta), inbox's "compose"
# page (always).
LJ::need_res(
# Explicitly specify "default" group to keep the CSS out of the
# 'all' group, because we almost never want it.
{ group => 'default' },
# base libraries
'js/6alib/core.js',
'js/6alib/dom.js',
'js/6alib/json.js',
# for the formatting of the icon selector popup
'js/6alib/template.js',
'js/6alib/ippu.js',
'js/lj_ippu.js',
# logic for the icon selector
'js/userpicselect.js',
# fetching the userpic information
'js/6alib/httpreq.js',
'js/6alib/hourglass.js',
# autocomplete
'js/6alib/inputcomplete.js',
'stc/ups.css',
# selecting an icon by clicking on a row
'js/6alib/datasource.js',
'js/6alib/selectable_table.js',
);
}
# convenience/deduplication function for QR, cut tag, & icon browser JS loading
# args: hash of opts to determine which JS files to load (iconbrowser, siteskin, lastn, noqr)
# returns: nothing, just calls need_res
sub init_s2journal_js {
my %opts = @_;
# TODO: change resource group to "foundation" in all these after the
# s2foundation beta ends.
# load for everywhere that displays entry and/or comment text.
# quick-reply.css is for both reply forms. (TODO: rename that.)
LJ::need_res(
{ group => "all" }, qw(
js/jquery/jquery.ui.widget.js
js/jquery.replyforms.js
stc/css/components/quick-reply.css
stc/css/components/icon-select.css
js/jquery.poll.js
js/journals/jquery.tag-nav.js
js/jquery.mediaplaceholder.js
js/jquery.imageshrink.js
js/components/jquery.icon-select.js
stc/css/components/imageshrink.css
)
);
# load for quick reply (every view except ReplyPage).
# threadexpander is only for EntryPage, but whatever.
LJ::need_res(
{ group => "all" }, qw(
js/jquery/jquery.ui.core.js
stc/jquery/jquery.ui.core.css
js/jquery/jquery.ui.widget.js
js/jquery.quickreply.js
js/jquery.threadexpander.js
)
) unless $opts{noqr};
# load only for ReplyPage
LJ::need_res(
{ group => "all" }, qw(
js/jquery.talkform.js
stc/css/components/talkform.css
)
) if $opts{noqr};
# load for userpicselect
if ( $opts{iconbrowser} ) {
my $remote = LJ::get_remote();
init_iconbrowser_js() if $remote && $remote->can_use_userpic_select;
}
# if we're using the site skin, don't override the jquery-ui theme,
# as that's already included
LJ::need_res(
{ group => "all" }, qw(
stc/jquery/jquery.ui.theme.smoothness.css
)
) unless $opts{siteskin};
# load for ajax cuttag and ajax quickreply - only needed on lastn-type pages
LJ::need_res(
{ group => "all" }, qw(
js/jquery/jquery.ui.widget.js
js/jquery.cuttag-ajax.js
js/jquery.default-editor.js
)
) if $opts{lastn};
}
# convenience function for keyboard shortcuts
# args: $remote and $p for adding javascript to header
# returns: nothing, just calls need_res
sub init_s2journal_shortcut_js {
my ( $remote, $p ) = @_;
# skip everything if there's no remote user, or if neither opt_shortcuts
# nor opt_shortcuts_touch is set.
return
unless $remote
&& ( $remote->prop("opt_shortcuts") || $remote->prop("opt_shortcuts_touch") );
my $connect_string = "";
LJ::need_res( { group => "all" }, "js/shortcuts.js" );
LJ::need_res( { group => "all" }, "js/jquery.shortcuts.nextentry.js" );
$p->{'head_content'} .= " <script type='text/javascript'>\n var dw_shortcuts = {\n";
if ( $remote->prop("opt_shortcuts") ) {
LJ::need_res( { group => "all" }, "js/mousetrap.js" );
$p->{'head_content'} .= " keyboard: {\n";
my $nextKey = $remote->prop("opt_shortcuts_next");
my $prevKey = $remote->prop("opt_shortcuts_prev");
$p->{'head_content'} .=
" nextEntry: '$nextKey',\n prevEntry: '$prevKey'\n }\n";
$connect_string = ",";
}
if ( $remote->prop("opt_shortcuts_touch") ) {
LJ::need_res( { group => "all" }, "js/jquery.touchSwipe.js" );
my $nextTouch = $remote->prop("opt_shortcuts_touch_next");
my $prevTouch = $remote->prop("opt_shortcuts_touch_prev");
$p->{'head_content'} .=
"$connect_string\n touch: {\n nextEntry: '$nextTouch',\n prevEntry: '$prevTouch'\n }\n";
}
$p->{'head_content'} .= " };\n </script>\n";
}
# <LJFUNC>
# name: LJ::record_anon_comment_ip
# class: web
# des: Records the IP address of an anonymous comment.
# args: journalu, jtalkid, ip
# des-journalu: User object of journal comment was posted in.
# des-jtalkid: ID of this comment.
# des-ip: IP address of the poster.
# returns: 1 for success, 0 for failure
# </LJFUNC>
sub record_anon_comment_ip {
my ( $journalu, $jtalkid, $ip ) = @_;
$journalu = LJ::want_user($journalu);
$jtalkid += 0;
return 0 unless LJ::isu($journalu) && $jtalkid && $ip;
$journalu->do(
"INSERT INTO tempanonips (reporttime, journalid, jtalkid, ip) VALUES (UNIX_TIMESTAMP(),?,?,?)",
undef, $journalu->{userid}, $jtalkid, $ip
);
return 0 if $journalu->err;
return 1;
}
# <LJFUNC>
# name: LJ::mark_comment_as_spam
# class: web
# des: Copies a comment into the global [dbtable[spamreports]] table.
# args: journalu, jtalkid
# des-journalu: User object of journal comment was posted in.
# des-jtalkid: ID of this comment.
# returns: 1 for success, 0 for failure
# </LJFUNC>
sub mark_comment_as_spam {
my ( $journalu, $jtalkid ) = @_;
$journalu = LJ::want_user($journalu);
$jtalkid += 0;
return 0 unless $journalu && $jtalkid;
my $dbcr = LJ::get_cluster_def_reader($journalu);
my $dbh = LJ::get_db_writer();
# step 1: get info we need
my $row = LJ::Talk::get_talk2_row( $dbcr, $journalu->{userid}, $jtalkid );
my $temp = LJ::get_talktext2( $journalu, $jtalkid );
my ( $subject, $body, $posterid ) =
( $temp->{$jtalkid}[0], $temp->{$jtalkid}[1], $row->{posterid} );
return 0 unless ( $body && $body ne '' );
# can't mark your own comments as spam.
return 0 if $posterid && $posterid == $journalu->id;
# can't mark comments as spam if sysbanned
return 0 if LJ::sysban_check( 'spamreport', $journalu->user );
# step 2a: if it's a suspended user, don't add, but pretend that we were successful
if ($posterid) {
my $posteru = LJ::want_user($posterid);
return 1 if $posteru->is_suspended;
}
# step 2b: if it was an anonymous comment, attempt to get comment IP to make some use of the report
my $ip;
unless ($posterid) {
$ip = $dbcr->selectrow_array( 'SELECT ip FROM tempanonips WHERE journalid=? AND jtalkid=?',
undef, $journalu->{userid}, $jtalkid );
return 0 if $dbcr->err;
# we want to fail out if we have no IP address and this is anonymous, because otherwise
# we have a completely useless spam report. pretend we were successful, too.
return 1 unless $ip;
# we also want to log this attempt so that we can do some throttling
my $rates = LJ::MemCache::get("spamreports:anon:$ip") || $RATE_DATAVER;
$rates .= pack( "N", time );
LJ::MemCache::set( "spamreports:anon:$ip", $rates );
}
my %props;
LJ::load_talk_props2( $dbcr, $journalu->{userid}, [$jtalkid], \%props );
# step 3: insert into spamreports
$dbh->do(
'INSERT INTO spamreports (reporttime, posttime, ip, journalid, posterid, subject, body, client) '
. 'VALUES (UNIX_TIMESTAMP(), UNIX_TIMESTAMP(?), ?, ?, ?, ?, ?, ?)',
undef,
$row->{datepost},
$ip,
$journalu->{userid},
$posterid,
$subject,
$body,
$props{$jtalkid}->{useragent}
);
return 0 if $dbh->err;
return 1;
}
# <LJFUNC>
# name: LJ::Talk::get_talk2_row
# class: web
# des: Gets a row of data from [dbtable[talk2]].
# args: dbcr, journalid, jtalkid
# des-dbcr: Database handle to read from.
# des-journalid: Journal id that comment is posted in.
# des-jtalkid: Journal talkid of comment.
# returns: Hashref of row data, or undef on error.
# </LJFUNC>
sub get_talk2_row {
my ( $dbcr, $journalid, $jtalkid ) = @_;
return $dbcr->selectrow_hashref(
'SELECT journalid, jtalkid, nodetype, nodeid, parenttalkid, '
. ' posterid, datepost, state '
. 'FROM talk2 WHERE journalid = ? AND jtalkid = ?',
undef,
$journalid + 0,
$jtalkid + 0
);
}
# <LJFUNC>
# name: LJ::Talk::get_talk2_row_multi
# class: web
# des: Gets multiple rows of data from [dbtable[talk2]].
# args: items
# des-items: Array of arrayrefs; each arrayref: [ journalu, jtalkid ].
# returns: Array of hashrefs of row data, or undef on error.
# </LJFUNC>
sub get_talk2_row_multi {
my (@items) = @_; # [ journalu, jtalkid ], ...
croak("invalid items for get_talk2_row_multi")
if grep { !LJ::isu( $_->[0] ) || @$_ != 2 } @items;
# what do we need to load per-journalid
my %need = (); # journalid => { jtalkid => 1, ... }
my %have = (); # journalid => { jtalkid => $row_ref, ... }
my %cluster = (); # cid => { jid => journalu, jid => journalu }
# first, what is in memcache?
my @keys = ();
foreach my $it (@items) {
my ( $journalu, $jtalkid ) = @$it;
# can't load comments in purged users' journals
next if $journalu->is_expunged;
my $cid = $journalu->clusterid;
my $jid = $journalu->id;
# we need this for now
$need{$jid}->{$jtalkid} = 1;
# which cluster is this user on?
$cluster{$cid}->{$jid} = $journalu;
push @keys, LJ::Talk::make_talk2row_memkey( $jid, $jtalkid );
}
# return an array of rows preserving order in which they were requested
my $ret = sub {
my @ret = ();
foreach my $it (@items) {
my ( $journalu, $jtalkid ) = @$it;
push @ret, $have{ $journalu->id }->{$jtalkid};
}
return @ret;
};
my $mem = LJ::MemCache::get_multi(@keys);
if ($mem) {
while ( my ( $key, $array ) = each %$mem ) {
my ( undef, $jid, $jtalkid ) = split( ":", $key );
my $row = LJ::MemCache::array_to_hash( "talk2row", $array );
next unless $row;
# add in implicit keys:
$row->{journalid} = $jid;
$row->{jtalkid} = $jtalkid;
# update our needs
$have{$jid}->{$jtalkid} = $row;
delete $need{$jid}->{$jtalkid};
delete $need{$jid} unless %{ $need{$jid} };
}
# was everything in memcache?
return $ret->() unless %need;
}
# uh oh, we have things to retrieve from the db!
CLUSTER:
foreach my $cid ( keys %cluster ) {
# build up a valid where clause for this cluster's select
my @vals = ();
my @where = ();
foreach my $journalu ( values %{ $cluster{$cid} } ) {
my $jid = $journalu->id;
my @jtalkids = keys %{ $need{$jid} };
next unless @jtalkids;
my $bind = join( ",", map { "?" } @jtalkids );
push @where, "(journalid=? AND jtalkid IN ($bind))";
push @vals, $jid => @jtalkids;
}
# is there anything to actually query for this cluster?
next CLUSTER unless @vals;
my $dbcr = LJ::get_cluster_reader($cid)
or die "unable to get cluster reader: $cid";
my $where = join( " OR ", @where );
my $sth =
$dbcr->prepare( "SELECT journalid, jtalkid, nodetype, nodeid, parenttalkid, "
. " posterid, datepost, state "
. "FROM talk2 WHERE $where" );
$sth->execute(@vals);
while ( my $row = $sth->fetchrow_hashref ) {
my $jid = $row->{journalid};
my $jtalkid = $row->{jtalkid};
# update our needs
$have{$jid}->{$jtalkid} = $row;
delete $need{$jid}->{$jtalkid};
delete $need{$jid} unless %{ $need{$jid} };
# update memcache
LJ::Talk::add_talk2row_memcache( $jid, $jtalkid, $row );
}
}
return $ret->();
}
sub make_talk2row_memkey {
my ( $jid, $jtalkid ) = @_;
return [ $jid, join( ":", "talk2row", $jid, $jtalkid ) ];
}
sub add_talk2row_memcache {
my ( $jid, $jtalkid, $row ) = @_;
my $memkey = LJ::Talk::make_talk2row_memkey( $jid, $jtalkid );
my $exptime = 60 * 30;
my $array = LJ::MemCache::hash_to_array( "talk2row", $row );
return LJ::MemCache::add( $memkey, $array, $exptime );
}
sub invalidate_talk2row_memcache {
my ( $jid, @jtalkids ) = @_;
foreach my $jtalkid (@jtalkids) {
my $memkey = [ $jid, "talk2row:$jid:$jtalkid" ];
LJ::MemCache::delete($memkey);
}
return 1;
}
# get a comment count for a journal entry.
sub get_replycount {
my ( $ju, $jitemid ) = @_;
$jitemid += 0;
return undef unless $ju && $jitemid;
my $memkey = [ $ju->{'userid'}, "rp:$ju->{'userid'}:$jitemid" ];
my $count = LJ::MemCache::get($memkey);
return $count if $count;
my $dbcr = LJ::get_cluster_def_reader($ju);
return unless $dbcr;
$count =
$dbcr->selectrow_array( "SELECT replycount FROM log2 WHERE " . "journalid=? AND jitemid=?",
undef, $ju->{'userid'}, $jitemid );
LJ::MemCache::add( $memkey, $count );
return $count;
}
# get the total amount of screened comments on the given journal entry
sub get_screenedcount {
my ( $ju, $jitemid ) = @_;
$jitemid += 0;
return undef unless $ju && $jitemid;
my $memkey = [ $ju->{userid}, "screenedcount:$ju->{userid}:$jitemid", 60 * 30 ];
my $count = LJ::MemCache::get($memkey);
return $count if $count;
my $dbcr = LJ::get_cluster_def_reader($ju);
return unless $dbcr;
$count = $dbcr->selectrow_array(
"SELECT COUNT(jtalkid) FROM talk2 WHERE " . "journalid=? AND nodeid=? AND state='S'",
undef, $ju->{userid}, $jitemid );
LJ::MemCache::add( $memkey, $count );
return $count;
}
sub comment_htmlid {
my $id = shift or return '';
return "cmt$id";
}
sub comment_anchor {
my $id = shift or return '';
return "#cmt$id";
}
sub treat_as_anon {
my ( $pu, $u ) = @_;
return 1 unless LJ::isu($pu); # anonymous is not OK
return 0 unless $pu->is_identity; # OK unless OpenID
# if OpenID, not OK unless they're granted access
return LJ::isu($u) ? !$u->trusts_or_has_member($pu) : 1;
}
sub format_eventtime {
my ( $etime, $u ) = @_;
$etime ||= '';
$etime =~ s!(\d{4}-\d{2}-\d{2})!LJ::date_to_view_links( $u, $1 )!e;
return "<br /><span class='time'>@ $etime</span>";
}
package LJ::Talk::Post;
use Text::Wrap;
use LJ::Entry;
sub indent {
my $a = shift;
my $leadchar = shift || " ";
$Text::Wrap::columns = 76;
return Text::Wrap::wrap( "$leadchar ", "$leadchar ", $a );
}
sub blockquote {
my $a = shift;
return
"<blockquote style='border-left: #000040 2px solid; margin-left: 0px; margin-right: 0px; padding-left: 15px; padding-right: 0px'>$a</blockquote>";
}
# An implementation detail of LJ::Talk::Post::post_comment; takes care of the
# gnarly database stuff. This is not expected to be called from anywhere else.
# (skull and crossbones emoji)
# Returns (1, talkid) on success, (0, error) on failure.
sub enter_comment {
my ($comment) = @_;
my $item = $comment->{entry};
my $journalu = $item->journal;
my $parent = $comment->{parent};
my $partid = $parent->{talkid};
my $itemid = $item->jitemid;
# accepts multi-part errors if you're into that.
my $err = sub {
return ( 0, join( ": ", @_ ) );
};
return $err->("Invalid user object passed.")
unless LJ::isu($journalu);
my $jtalkid = LJ::alloc_user_counter( $journalu, "T" );
return $err->( "Database Error", "Could not generate a talkid necessary to post this comment." )
unless $jtalkid;
# insert the comment
my $posterid = $comment->{u} ? $comment->{u}{userid} : 0;
my $errstr;
$journalu->talk2_do(
"L",
$itemid,
\$errstr,
"INSERT INTO talk2 "
. "(journalid, jtalkid, nodetype, nodeid, parenttalkid, posterid, datepost, state) "
. "VALUES (?,?,'L',?,?,?,NOW(),?)",
$journalu->{userid},
$jtalkid,
$itemid,
$partid,
$posterid,
$comment->{state}
);
if ($errstr) {
return $err->(
"Database Error",
"There was an error posting your comment to the database. "
. "Please report this. The error is: <b>$errstr</b>"
);
}
LJ::MemCache::incr( [ $journalu->{'userid'}, "talk2ct:$journalu->{'userid'}" ] );
# record IP if anonymous
LJ::Talk::record_anon_comment_ip( $journalu, $jtalkid, LJ::get_remote_ip() )
unless $posterid;
# add to poster's talkleft table, or the xfer place
if ($posterid) {
my $table;
my $db = LJ::get_cluster_master( $comment->{u} );
if ($db) {
# remote's cluster is writable
$table = "talkleft";
}
else {
# log to global cluster, another job will move it later.
$db = LJ::get_db_writer();
$table = "talkleft_xfp";
}
my $pub = $item->security eq "public" ? 1 : 0;
if ($db) {
$db->do(
"INSERT INTO $table (userid, posttime, journalid, nodetype, "
. "nodeid, jtalkid, publicitem) VALUES (?, UNIX_TIMESTAMP(), "
. "?, 'L', ?, ?, ?)",
undef, $posterid, $journalu->{userid}, $itemid, $jtalkid, $pub
);
LJ::MemCache::incr( [ $posterid, "talkleftct:$posterid" ] );
}
else {
# both primary and backup talkleft hosts down. can't do much now.
}
}
$journalu->do(
"INSERT INTO talktext2 (journalid, jtalkid, subject, body) " . "VALUES (?, ?, ?, ?)",
undef,
$journalu->{userid},
$jtalkid,
$comment->{subject},
LJ::text_compress( $comment->{body} )
);
die $journalu->errstr if $journalu->err;
my $memkey = "$journalu->{'clusterid'}:$journalu->{'userid'}:$jtalkid";
LJ::MemCache::set( [ $journalu->{'userid'}, "talksubject:$memkey" ], $comment->{subject} );
LJ::MemCache::set( [ $journalu->{'userid'}, "talkbody:$memkey" ], $comment->{body} );
LJ::MemCache::delete( [ $journalu->{userid}, "activeentries:" . $journalu->{userid} ] );
LJ::MemCache::delete( [ $journalu->{userid}, "screenedcount:$journalu->{userid}:$itemid" ] )
if $comment->{state} eq 'S';
# dudata
my $bytes = length( $comment->{subject} ) + length( $comment->{body} );
# we used to do a LJ::dudata_set(..) on 'T' here, but decided
# we could defer that. to find size of a journal, summing
# bytes in dudata is too slow (too many seeks)
my %talkprop; # propname -> value
# meta-data
$talkprop{'unknown8bit'} = 1 if $comment->{unknown8bit};
$talkprop{'subjecticon'} = $comment->{subjecticon};
my $pu = $comment->{u};
if ( $pu && $pu->userpic_have_mapid ) {
$talkprop{picture_mapid} = $pu->get_mapid_from_keyword( $comment->{picture_keyword} );
}
else {
$talkprop{picture_keyword} = $comment->{picture_keyword};
}
$talkprop{admin_post} = $comment->{admin_post} ? 1 : 0;
$talkprop{'opt_preformatted'} = $comment->{preformat} ? 1 : 0;
$talkprop{'editor'} = $comment->{editor};
my $site_user_comment = $comment->{u} && $comment->{u}->is_person;
if ( $journalu->opt_logcommentips eq "A"
|| ( $journalu->opt_logcommentips eq "S" && !$site_user_comment ) )
{
if ( LJ::is_web_context() ) {
my $ip = BML::get_remote_ip();
my $forwarded = BML::get_client_header('X-Forwarded-For');
$ip = "$forwarded, via $ip" if $forwarded && $forwarded ne $ip;
$talkprop{'poster_ip'} = $ip;
}
}
# remove blank/0 values (defaults)
foreach ( keys %talkprop ) { delete $talkprop{$_} unless $talkprop{$_}; }
# update the talkprops
LJ::load_props("talk");
if (%talkprop) {
my $values;
my $hash = {};
foreach ( keys %talkprop ) {
my $p = LJ::get_prop( "talk", $_ );
next unless $p;
$hash->{$_} = $talkprop{$_};
my $tpropid = $p->{'tpropid'};
my $qv = $journalu->quote( $talkprop{$_} );
$values .= "($journalu->{'userid'}, $jtalkid, $tpropid, $qv),";
}
if ($values) {
chop $values;
$journalu->do(
"INSERT INTO talkprop2 (journalid, jtalkid, tpropid, value) " . "VALUES $values" );
die $journalu->errstr if $journalu->err;
}
LJ::MemCache::set( [ $journalu->{'userid'}, "talkprop:$journalu->{'userid'}:$jtalkid" ],
$hash );
}
# update the "replycount" summary field of the log table
if ( $comment->{state} eq 'A' ) {
LJ::replycount_do( $journalu, $itemid, "incr" );
}
# update the "hasscreened" property of the log item if needed
if ( $comment->{state} eq 'S' ) {
LJ::set_logprop( $journalu, $itemid, { 'hasscreened' => 1 } );
}
# update the comment alter property
LJ::Talk::update_commentalter( $journalu, $itemid );
# fire events
my @jobs;
push @jobs,
LJ::Event::JournalNewComment->new( LJ::Comment->new( $journalu, jtalkid => $jtalkid ) );
if (@LJ::SPHINX_SEARCHD) {
push @jobs,
DW::Task::SphinxCopier->new(
{ userid => $journalu->id, jtalkid => $jtalkid, source => "commtnew" } );
}
DW::TaskQueue->dispatch(@jobs) if @jobs;
return ( 1, $jtalkid );
}
# this is used by the journal import code, but is kept here so as to be kept
# local to the rest of the comment code
sub enter_imported_comment {
my ( $journalu, $parent, $item, $comment, $date, $errref ) = @_;
my $partid = $parent->{talkid};
my $itemid = $item->{itemid};
my $posterid = $comment->{u} ? $comment->{u}->{userid} : 0;
my $err = sub {
$$errref = join( ": ", @_ );
return 0;
};
return $err->("Invalid user object passed.")
unless LJ::isu($journalu);
# prealloc counter before insert
my $jtalkid = LJ::alloc_user_counter( $journalu, "T" );
return $err->( "Database Error", "Could not generate a talkid necessary to post this comment." )
unless $jtalkid;
# insert the comment
my $errstr;
$journalu->talk2_do(
"L", $itemid, \$errstr,
q{
INSERT INTO talk2 (journalid, jtalkid, nodetype, nodeid, parenttalkid, posterid, datepost, state)
VALUES (?,?,'L',?,?,?,?,?)
},
$journalu->{userid}, $jtalkid, $itemid, $partid, $posterid, $date, $comment->{state}
);
return $err->(
"Database Error",
"There was an error posting your comment to the database. "
. "Please report this. The error is: <b>$errstr</b>"
) if $errstr;
LJ::MemCache::delete( [ $journalu->{userid}, "talk2ct:$journalu->{userid}" ] );
# add to poster's talkleft table, or the xfer place
if ($posterid) {
my $table;
my $db = LJ::get_cluster_master( $comment->{u} );
if ($db) {
# remote's cluster is writable
$table = "talkleft";
}
else {
# log to global cluster, another job will move it later.
$db = LJ::get_db_writer();
$table = "talkleft_xfp";
}
my $pub = $item->{'security'} eq "public" ? 1 : 0;
if ($db) {
$db->do(
qq{
INSERT INTO $table (userid, posttime, journalid, nodetype, nodeid, jtalkid, publicitem)
VALUES (?, UNIX_TIMESTAMP(?), ?, 'L', ?, ?, ?)
}, undef, $posterid, $date, $journalu->{userid}, $itemid, $jtalkid, $pub
);
LJ::MemCache::delete( [ $posterid, "talkleftct:$posterid" ] );
}
else {
# both primary and backup talkleft hosts down. can't do much now.
warn "Unable to insert comment into talkleft, cluster+master down?";
}
}
if ( $comment->{state} ne "D" ) {
$journalu->do(
q{
INSERT INTO talktext2 (journalid, jtalkid, subject, body)
VALUES (?, ?, ?, ?)
}, undef, $journalu->{userid}, $jtalkid, $comment->{subject},
LJ::text_compress( $comment->{body} )
);
die $journalu->errstr if $journalu->err;
}
my %talkprop; # propname -> value
foreach my $key ( keys %{ $comment->{props} || {} } ) {
$talkprop{$key} = $comment->{props}->{$key};
}
$talkprop{'unknown8bit'} = 1 if $comment->{unknown8bit};
$talkprop{'subjecticon'} = $comment->{subjecticon};
my $pu = $comment->{u};
if ( $pu && $pu->userpic_have_mapid ) {
$talkprop{picture_mapid} =
$pu->get_mapid_from_keyword( $comment->{picture_keyword}, create => 1 );
}
else {
$talkprop{picture_keyword} = $comment->{picture_keyword};
}
$talkprop{'opt_preformatted'} = $comment->{preformat} ? 1 : 0;
# remove blank/0 values (defaults)
foreach ( keys %talkprop ) {
delete $talkprop{$_} unless $talkprop{$_};
}
# update the talkprops
LJ::load_props("talk");
if (%talkprop) {
my $values;
my $hash = {};
foreach ( keys %talkprop ) {
my $p = LJ::get_prop( "talk", $_ );
next unless $p;
$hash->{$_} = $talkprop{$_};
my $tpropid = $p->{'tpropid'};
my $qv = $journalu->quote( $talkprop{$_} );
$values .= "($journalu->{'userid'}, $jtalkid, $tpropid, $qv),";
}
if ($values) {
chop $values;
$journalu->do(
"INSERT INTO talkprop2 (journalid, jtalkid, tpropid, value) " . "VALUES $values" );
die $journalu->errstr if $journalu->err;
}
}
# update the "replycount" summary field of the log table
if ( $comment->{state} eq 'A' || $comment->{state} eq 'F' ) {
LJ::replycount_do( $journalu, $itemid, "incr" );
}
# update the "hasscreened" property of the log item if needed
if ( $comment->{state} eq 'S' ) {
LJ::set_logprop( $journalu, $itemid, { 'hasscreened' => 1 } );
}
# update the comment alter property
LJ::Talk::update_commentalter( $journalu, $itemid );
return $jtalkid;
}
# Checks permissions and consistency for a submitted comment, then returns a
# comment hashref that can be passed to post_comment or edit_comment (or undef
# if the comment wouldn't be allowed).
# Replacement for LJ::Talk::Post::init.
# This ONLY deals with the content and relationships of the comment itself;
# user authentication and frontend concerns belong elsewhere.
#
# Args:
# $content: a reply form hashref, representing the comment as submitted.
# Mostly won't get mutated here, but the captcha check requires minor
# finagling. Fields we use:
# - body
# - subject
# - prop_something (various)
# - editid and editreason, if editing
# - parenttalkid: integer, comment being replied to (0 if replying to entry)
# - replyto: duplicate of parenttalkid, for some reason
# - subjecticon
# - any captcha-related fields from the talkform (varies by captcha type)
# Other fields are ignored.
# $commenter: user object, or undef for anonymous
# $entry: LJ::Entry object
# $need_captcha: scalar ref to mutate; if the caller can't ask a human
# for a captcha response, it probably bails if this comes back truthy.
# $errret: array ref to push errors to. If we return undef, this says why.
sub prepare_and_validate_comment {
my ( $content, $commenter, $entry, $need_captcha, $errret ) = @_;
my $tp_d = '/talkpost_do.tt'; # for ml strings
# Commenter can be undef for anon, but yes, we absolutely need an entry.
croak("Need LJ::Entry object to reply to") unless $entry->isa('LJ::Entry');
# For most errors, report and keep going; we'll return undef at the end, and
# the user can address them all at once. ~But if it's all gone wrong &
# there's nothing left to learn: go ahead and return~ (guitar)
my $err = sub {
my $error = shift;
push @$errret, $error;
return undef;
};
my $mlerr = sub {
return $err->( LJ::Lang::ml(@_) );
};
my $journalu = $entry->journal;
my $commenter_is_user = LJ::isu($commenter);
# First: accept the things u cannot change. Existential errors a commenter
# can't do anything about.
# Can the user even view this post?
unless ( $entry->visible_to($commenter) ) {
$mlerr->("$tp_d.error.mustlogin") unless $commenter_is_user;
$mlerr->("$tp_d.error.noauth");
return undef; # Shouldn't tell you anything else about this entry, then.
}
# No replying to readonly/locked/expunged journals
return $mlerr->("$tp_d.error.noreply_readonly_journal") if $journalu->is_readonly;
return $mlerr->('talk.error.purged') if $journalu->is_expunged;
return $err->("Account is locked, unable to post or edit a comment.") if $journalu->is_locked;
# can ANYONE comment?
return $mlerr->("$tp_d.error.nocomments") if $entry->comments_disabled;
# no replying to suspended entries, even by entry poster
return $mlerr->("$tp_d.error.noreply_suspended") if $entry->is_suspended;
# check max comments (unless editing existing comment)
if ( !$content->{editid} && over_maxcomments( $journalu, $entry->jitemid ) ) {
return $mlerr->("$tp_d.error.maxcomments");
}
# If replying to a comment, it's gotta exist. (Hold onto it, we'll want it later.)
my $parenttalkid = ( $content->{parenttalkid} || $content->{replyto} || 0 ) + 0;
my $parpost;
if ($parenttalkid) {
my $dbcr = LJ::get_cluster_def_reader($journalu);
return $mlerr->('error.nodb') unless $dbcr; # tbh we got bigger problems at this point.
$parpost = LJ::Talk::get_talk2_row( $dbcr, $journalu->{userid}, $parenttalkid );
unless ($parpost) {
return $mlerr->("$tp_d.error.noparent");
}
}
# no replying to frozen comments
my $parent_state = $parpost->{state} // '';
return $mlerr->("$tp_d.error.noreply_frozen") if $parent_state eq 'F';
# Next: Permissions checks! Easily solved, just become someone else.
# (For switching between variant error messages.)
my $iscomm = $journalu->is_community ? '.comm' : '';
if ($commenter_is_user) {
# test accounts can only comment on other test accounts.
if ( ( grep { $commenter->user eq $_ } @LJ::TESTACCTS )
&& !( grep { $journalu->user eq $_ } @LJ::TESTACCTS )
&& !$LJ::IS_DEV_SERVER )
{
$mlerr->("$tp_d.error.testacct");
}
# Ban check for journal and entry:
if ( $journalu->has_banned($commenter) ) {
$mlerr->("$tp_d.error.banned$iscomm");
}
else {
# comm hasn't banned you, but maybe this poster did
$mlerr->("$tp_d.error.banned.entryowner") if $entry->poster->has_banned($commenter);
}
# Ban check for parent comment:
my $parentu = LJ::load_userid( $parpost->{posterid} );
$mlerr->("$tp_d.error.banned.reply")
if defined $parentu && $parentu->has_banned($commenter);
# they down with unvalidated OpenIDs?
if ( $journalu->does_not_allow_comments_from_unconfirmed_openid($commenter) ) {
$mlerr->(
"$tp_d.error.noopenidpost",
{
aopts1 => "href='$LJ::SITEROOT/changeemail'",
aopts2 => "href='$LJ::SITEROOT/register'"
}
);
}
# No one's down with unvalidated site users.
# (FYI, nothing in -free or -nonfree ever registers that hook. -NF)
if ( $commenter->{'status'} eq "N"
&& !$commenter->is_identity
&& !LJ::Hooks::run_hook( "journal_allows_unvalidated_commenting", $journalu ) )
{
$mlerr->( "$tp_d.error.noverify2", { aopts => "href='$LJ::SITEROOT/register'" } );
}
# Miscellaneous miscreants:
$mlerr->("$tp_d.error.purged") if $commenter->is_expunged;
$mlerr->("$tp_d.error.deleted") if $commenter->is_deleted;
$mlerr->("$tp_d.error.suspended") if $commenter->is_suspended;
$mlerr->("$tp_d.error.noreply_readonly_remote") if $commenter->is_readonly;
# members only?
if ( $journalu->does_not_allow_comments_from_non_access($commenter) ) {
my $msg = $journalu->is_community ? "notamember" : "notafriend";
$mlerr->( "$tp_d.error.$msg", { user => $journalu->user } );
}
}
else {
# I think these would all have been handled by the checks in the other
# branch, but tradition says anons get different error messages.
# Doesn't allow anon comments?
if ( $journalu->does_not_allow_comments_from($commenter) ) {
$mlerr->("$tp_d.error.noanon$iscomm");
}
# members only?
if ( $journalu->prop('opt_whocanreply') eq 'friends' ) {
my $msg = $journalu->is_community ? "membersonly" : "friendsonly";
$mlerr->( "$tp_d.error.$msg", { user => $journalu->user } );
}
}
# Next: consistency checks and munging!
# (And captcha, after that.)
# Old init had some UTF8 conversion thing here for POSTs to talkpost_do that
# included an "encoding" field, but I can't find any way that can possibly
# happen. Let's just explode instead. -NF
return $mlerr->("bml.badinput.body1") unless LJ::text_in($content);
my $body = $content->{body};
my $subject = $content->{subject};
# Cat got your tongue?
$mlerr->("$tp_d.error.blankmessage") unless $body =~ /\S/;
# unixify line-endings
$body =~ s/\r\n/\n/g;
# Length check:
my ( $bl, $cl ) = LJ::text_length($body);
if ( $cl > LJ::CMAX_COMMENT ) {
$mlerr->(
"$tp_d.error.manychars",
{
current => $cl,
limit => LJ::CMAX_COMMENT
}
);
}
elsif ( $bl > LJ::BMAX_COMMENT ) {
$mlerr->(
"$tp_d.error.manybytes",
{
current => $bl,
limit => LJ::BMAX_COMMENT
}
);
}
# the subject can be silently shortened, no need to reject the whole comment
$subject = LJ::text_trim( $subject, 100, 100 );
# munge subjecticons, not to be confused with Decepticons (or regular icons)
my $subjecticon = $content->{'subjecticon'} || '';
$subjecticon = LJ::trim( lc($subjecticon) );
$subjecticon = '' if $subjecticon eq "none";
# anti-spam captcha check
unless ( ref $need_captcha eq 'SCALAR' ) {
my $nevermind = 0;
$need_captcha = \$nevermind;
}
# If the form already had a captcha, prep it:
$content->{want} = $content->{captcha_type}; # Captcha->new consumes "want"
my $captcha = DW::Captcha->new( undef, %{ $content || {} } );
# are they sending us a response? Check it.
if ( $captcha->enabled && $captcha->has_response ) {
# If this isn't their final pass through the form, they'll need a captcha next time too.
$$need_captcha = 1;
# TODO: I'd rather only ask for one captcha per interaction.
my $captcha_error;
$err->($captcha_error) unless ( $captcha->validate( err_ref => \$captcha_error ) );
}
else {
$$need_captcha =
LJ::Talk::Post::require_captcha_test( $commenter, $journalu, $body, $entry );
$err->( LJ::Lang::ml('captcha.title') ) if $$need_captcha;
}
# That's the end of Things that Ain't Valid! Roll em up and bail now so the
# user can fix.
return undef if @$errret;
# Ok!! Home free, and almost done.
# post this comment screened?
my $state = 'A';
my $screening = LJ::Talk::screening_level( $journalu, $entry->jitemid ) || "";
$screening = 'A' if $journalu->has_autoscreen($commenter);
if ( $screening eq 'A'
|| ( $screening eq 'R' && !$commenter_is_user )
|| ( $screening eq 'F' && !( $commenter && $journalu->trusts_or_has_member($commenter) ) ) )
{
$state = 'S';
}
# Finally, ensure that the comment ends up screen if it runs afoul of some
# testing we do. I.e., if the journal is in the auto-screen list and the IP is
# in the bad IPs list.
if ( exists $LJ::AUTOSCREEN_COMMENTS_IN{ $journalu->user } ) {
if ( $LJ::SHOULD_SCREEN_IP && $LJ::SHOULD_SCREEN_IP->( LJ::get_remote_ip() ) ) {
$state = 'S';
}
}
# Assemble the final prepared comment!
my $parent = {
state => $parent_state,
talkid => $parenttalkid,
posterid => $parpost->{posterid},
};
my $comment = {
u => $commenter,
parent => $parent,
entry => $entry,
subject => $subject,
body => $body,
unknown8bit => 0,
subjecticon => $subjecticon,
# TODO need a more organized way to carry approved props forward.
editor => DW::Formats::validate( $content->{'prop_editor'} ),
preformat => $content->{'prop_opt_preformatted'},
admin_post => $content->{'prop_admin_post'},
picture_keyword => $content->{'prop_picture_keyword'},
state => $state,
editid => $content->{editid},
editreason => $content->{editreason},
};
return $comment;
}
# <LJFUNC>
# name: LJ::Talk::Post::require_captcha_test
# des: returns true if user must answer CAPTCHA (human test) before posting a comment
# args: commenter, journal, body, entry
# des-commenter: User object of author of comment, undef for anonymous commenter
# des-journal: User object of journal where to post comment
# des-body: Text of the comment (may be checked for spam, may be empty)
# des-entry: LJ::Entry object for the entry being commented on
# </LJFUNC>
sub require_captcha_test {
my ( $commenter, $journal, $body, $entry ) = @_;
my $ditemid = $entry->ditemid;
# only require captcha if the site is properly configured for it
return 0 unless DW::Captcha->site_enabled;
## anonymous commenter user =
## not logged-in user, or OpenID without validated e-mail
my $anon_commenter = !LJ::isu($commenter)
|| ( $commenter->identity && !$commenter->is_validated );
##
## 1. Check rate by remote user and by IP (for anonymous user)
##
my $captcha = DW::Captcha->new;
if ( $captcha->enabled('anonpost') || $captcha->enabled('authpost') ) {
return 1 unless LJ::Talk::Post::check_rate( $commenter, $journal );
}
if ( $captcha->enabled('anonpost') && $anon_commenter ) {
return 1 if LJ::sysban_check( 'talk_ip_test', LJ::get_remote_ip() );
}
##
## 4. Test preliminary limit on comment.
## We must check it before we will allow owner to pass.
##
if ( LJ::Talk::get_replycount( $journal, $ditemid >> 8 ) >=
$journal->count_maxcomments_before_captcha )
{
# Skip the forced captcha for recent entries (posted within the last
# 30 days). High-comment spam mostly targets old/abandoned entries,
# and active anon memes shouldn't be penalized for popularity.
my $age_days = ( time() - $entry->logtime_unix ) / 86400;
return 1 if $age_days > 30;
}
##
## 2. Don't show captcha to the owner of the journal, no more checks
##
if ( !$anon_commenter && $commenter->equals($journal) ) {
return 0;
}
##
## 3. Custom (journal) settings
##
my $show_captcha_to = $journal->prop('opt_show_captcha_to');
if ( !$show_captcha_to || $show_captcha_to eq 'N' ) {
## no one
}
elsif ( $show_captcha_to eq 'R' ) {
## anonymous
return 1 if $anon_commenter;
}
elsif ( $show_captcha_to eq 'F' ) {
## not friends
return 1 if !$journal->trusts_or_has_member($commenter);
}
elsif ( $show_captcha_to eq 'A' ) {
## all
return 1;
}
##
## 4. Global (site) settings
## See if they have any tags or URLs in the comment's body
##
if ( $captcha->enabled('comment_html_auth')
|| ( $captcha->enabled('comment_html_anon') && $anon_commenter ) )
{
return 0 unless $body; # Before we bother matching against it.
if ( $body =~ /<[a-z]/i ) {
# strip white-listed bare tags w/o attributes,
# then see if they still have HTML. if so, it's
# questionable. (can do evil spammy-like stuff w/
# attributes and other elements)
my $body_copy = $body;
$body_copy =~ s/<(?:q|blockquote|b|strong|i|em|cite|sub|sup|var|del|tt|code|pre|p)>//ig;
return 1 if $body_copy =~ /<[a-z]/i;
}
# multiple URLs is questionable too
return 1 if $body =~ /\b(?:http|ftp|www)\b.+\b(?:http|ftp|www)\b/s;
# or if they're not even using HTML
return 1 if $body =~ /\[url/is;
# or if it's obviously spam
return 1 if $body =~ /\s*message\s*/is;
}
}
# Does what it says on the tin.
# Expects a comment hashref from LJ::Talk::Post::prepare_and_validate_comment.
# Don't yolo one of these hashrefs unless you're in a test.
# returns (1, talkid) on success, (0, error) on fail
# Mutates its received $comment hashref: maybe setting state,
# maybe removing picture_keyword, //= '' on a few things.
sub post_comment {
my ( $comment, $unscreen_parent ) = @_;
my $item = $comment->{entry};
my $journalu = $item->journal;
my $parent = $comment->{parent};
my $itemid = $item->jitemid;
my $parent_state = $parent->{state} || "";
# unscreen the parent comment if needed
if ( $parent_state eq 'S' && $unscreen_parent ) {
# if parent comment is screened and we got this far, the user has the permission to unscreen it
# in this case the parent comment needs to be unscreened and the comment posted as normal
LJ::Talk::unscreen_comment( $journalu, $itemid, $parent->{talkid} );
$parent->{state} = 'A';
}
elsif ( $parent_state eq 'S' ) {
# if the parent comment is screened and the unscreen option was not selected, we also want the
# reply to be posted as screened
$comment->{state} = 'S';
}
# check for duplicate entry (double submission)
# Note: we don't do it inside a locked section like LJ::Protocol's postevent,
# so it's not perfect, but it works pretty well.
my $posterid = $comment->{u} ? $comment->{u}{userid} : 0;
my $jtalkid;
# check for dup ID in memcache.
my $memkey;
if (@LJ::MEMCACHE_SERVERS) {
# avoid warnings FIXME this should be done elsewhere
foreach my $field (qw(body subject subjecticon preformat editor admin_post picture_keyword))
{
$comment->{$field} = '' if not defined $comment->{$field};
}
my $md5_b64 = Digest::MD5::md5_base64(
join(
":",
(
$comment->{body}, $comment->{subject},
$comment->{subjecticon}, $comment->{preformat},
$comment->{editor}, $comment->{picture_keyword}
)
)
);
$memkey = [
$journalu->{userid},
"tdup:$journalu->{userid}:$itemid-$parent->{talkid}-$posterid-$md5_b64"
];
$jtalkid = LJ::MemCache::get($memkey);
}
# they don't have a duplicate...
unless ($jtalkid) {
my ( $posteru, $kw ) = ( $comment->{u}, $comment->{picture_keyword} );
# XXX do select and delete $talkprop{'picture_keyword'} if they're lying
my $pic = LJ::Userpic->new_from_keyword( $posteru, $kw );
delete $comment->{picture_keyword} unless $pic && $pic->state eq 'N';
# put the post in the database
my ( $ok, $talkid_or_err ) = enter_comment($comment);
return ( 0, $talkid_or_err ) unless $ok;
$jtalkid = $talkid_or_err;
# save its identifying characteristics to protect against duplicates.
LJ::MemCache::set( $memkey, $jtalkid + 0, time() + 60 * 10 );
}
# cluster tracking
LJ::mark_user_active( $comment->{u}, 'comment' );
DW::Stats::increment(
'dw.action.comment.post',
1,
[
"journal_type:" . $journalu->journaltype_readable,
"poster_type:"
. ( ref $comment->{u} ? $comment->{u}->journaltype_readable : 'anonymous' )
]
);
LJ::Hooks::run_hooks( 'new_comment', $journalu->{userid}, $itemid, $jtalkid )
; # This hook is never registered by anything in -free or -nonfree. -NF
return ( 1, $jtalkid );
}
# Does what it says on the tin.
# Expects a comment hashref from LJ::Talk::Post::prepare_and_validate_comment.
# Don't yolo one of these hashrefs unless you're in a test.
# returns (1, talkid) on success, (0, error) on fail
sub edit_comment {
my ($comment) = @_;
my $item = $comment->{entry};
my $journalu = $item->journal;
my $comment_obj = LJ::Comment->new( $journalu, dtalkid => $comment->{editid} );
my $remote = LJ::get_remote();
my $edit_error;
return ( 0, $edit_error ) unless $comment_obj->remote_can_edit( \$edit_error );
my %props = (
subjecticon => $comment->{subjecticon},
opt_preformatted => $comment->{preformat} ? 1 : 0,
admin_post => $comment->{admin_post} ? 1 : 0,
editor => $comment->{editor},
edit_reason => $comment->{editreason},
);
# set to undef if we have blank/0 values (set_props will delete these from the DB later)
foreach ( keys %props ) { $props{$_} = undef unless $props{$_}; }
my $pu = $comment_obj->poster;
if ( $pu && $pu->userpic_have_mapid ) {
$props{picture_mapid} = $pu->get_mapid_from_keyword( $comment->{picture_keyword} );
}
else {
$props{picture_keyword} = $comment->{picture_keyword};
}
# set most of the props together
$comment_obj->set_props(%props);
# set edit time separately since it needs to be a raw value
$comment_obj->set_prop_raw( edit_time => "UNIX_TIMESTAMP()" );
# set poster IP separately since it has special conditions
my $opt_logcommentips = $comment_obj->journal->opt_logcommentips;
my $site_user_comment = $comment->{u} && $comment->{u}->is_person;
if ( $opt_logcommentips eq "A"
|| ( $opt_logcommentips eq "S" && !$site_user_comment ) )
{
$comment_obj->set_poster_ip;
}
# set subject and body text
$comment_obj->set_subject_and_body( $comment->{subject}, $comment->{body} );
# If we need to rescreen the comment, do so now.
my $state = $comment->{state} || "";
if ( $state eq 'S' ) {
LJ::Talk::screen_comment( $journalu, $item->jitemid, $comment_obj->jtalkid );
}
# cluster tracking
LJ::mark_user_active( $pu, 'comment' );
# fire events
my @jobs;
push @jobs, LJ::Event::JournalNewComment::Edited->new($comment_obj);
if (@LJ::SPHINX_SEARCHD) {
push @jobs,
DW::Task::SphinxCopier->new(
{ userid => $journalu->id, jtalkid => $comment_obj->jtalkid, source => "commtedt" } );
}
DW::TaskQueue->dispatch(@jobs) if @jobs;
DW::Stats::increment(
'dw.action.comment.edit',
1,
[
"journal_type:" . $journalu->journaltype_readable,
"poster_type:" . $pu ? $pu->journaltype_readable : 'anonymous'
]
);
return ( 1, $comment_obj->jtalkid );
}
# given a journalu and jitemid, return 1 if the entry
# is over the maximum comments allowed.
sub over_maxcomments {
my ( $journalu, $jitemid ) = @_;
$journalu = LJ::want_user($journalu);
$jitemid += 0;
return 0 unless $journalu && $jitemid;
my $count = LJ::Talk::get_replycount( $journalu, $jitemid );
return ( $count >= $journalu->count_maxcomments ) ? 1 : 0;
}
# more anti-spammer rate limiting. returns 1 if rate is okay, 0 if too fast.
sub check_rate {
my ( $remote, $journalu ) = @_;
# we require memcache to do rate limiting efficiently
return 1 unless @LJ::MEMCACHE_SERVERS;
# return right away if the account is suspended
return 0 if $remote && ( $remote->is_suspended || $remote->is_deleted );
# allow some users to be very aggressive commenters and authors. i.e. our bots.
return 1
if $remote
and grep { $remote->username eq $_ } @LJ::NO_RATE_CHECK_USERS;
my $ip = LJ::get_remote_ip();
my $now = time();
my @watch;
if ($remote) {
# registered human (or human-impersonating robot)
push @watch,
[
"talklog:$remote->{userid}", $LJ::RATE_COMMENT_AUTH || [ [ 200, 3600 ], [ 20, 60 ] ],
];
}
else {
# anonymous, per IP address (robot or human)
push @watch,
[
"talklog:$ip",
$LJ::RATE_COMMENT_ANON || [ [ 300, 3600 ], [ 200, 1800 ], [ 150, 900 ], [ 15, 60 ] ]
];
# anonymous, per journal.
# this particular limit is intended to combat flooders, instead
# of the other 'spammer-centric' limits.
push @watch,
[
"talklog:anonin:$journalu->{userid}",
$LJ::RATE_COMMENT_ANON || [ [ 300, 3600 ], [ 200, 1800 ], [ 150, 900 ], [ 15, 60 ] ]
];
# throttle based on reports of spam
push @watch,
[ "spamreports:anon:$ip", $LJ::SPAM_COMMENT_RATE || [ [ 50, 86400 ], [ 10, 3600 ] ] ];
}
WATCH:
foreach my $watch (@watch) {
my ( $key, $rates ) = ( $watch->[0], $watch->[1] );
my $max_period = $rates->[0]->[1];
my $log = LJ::MemCache::get($key) || "";
# parse the old log
my @times;
if ( length($log) % 4 == 1 && substr( $log, 0, 1 ) eq $RATE_DATAVER ) {
my $ct = ( length($log) - 1 ) / 4;
for ( my $i = 0 ; $i < $ct ; $i++ ) {
my $time = unpack( "N", substr( $log, $i * 4 + 1, 4 ) );
push @times, $time if $time > $now - $max_period;
}
}
# add this event unless we're throttling based on spamreports
push @times, $now unless $key =~ /^spamreports/;
# check rates
foreach my $rate (@$rates) {
my ( $allowed, $period ) = ( $rate->[0], $rate->[1] );
my $events = scalar grep { $_ > $now - $period } @times;
if ( $events > $allowed ) {
last WATCH;
}
}
# build the new log
my $newlog = $RATE_DATAVER;
foreach (@times) {
$newlog .= pack( "N", $_ );
}
LJ::MemCache::set( $key, $newlog, $max_period );
}
return 1;
}
1;