package DW::Controller::Talk; use strict; use LJ::JSON; use DW::Controller; use DW::Routing; use DW::Template; use DW::Formats; DW::Routing->register_string( '/talkpost_do', \&talkpost_do_handler, app => 1 ); DW::Routing->register_string( '/talkmulti', \&talkmulti_handler, app => 1 ); DW::Routing->register_string( '/talkscreen', \&talkscreen_handler, app => 1 ); DW::Routing->register_string( '/delcomment', \&delcomment_handler, app => 1 ); DW::Routing->register_rpc( "delcomment", \&delcomment_handler, format => 'json', methods => { POST => 1 } ); DW::Routing->register_rpc( "talkscreen", \&talkscreen_handler, format => 'json', methods => { POST => 1 } ); # I think this canon isn't written down anywhere else, so HWAET: I sing a record # of every damn form field that comes through in reply form POSTs. -NF # # Form names: # - qrform # The quick-reply form, built by the LJ::create_qr_div function, in # LJ/Web.pm. # - postform # The talkform, built by the LJ::Talk::talkform function. # - There used to also be a previewform, but it was a menace and I killed it. # # Actual comment content (which gets saved to the DB): # - subject # - body # - prop_picture_keyword # The user icon for this comment. # - prop_opt_preformatted # The "don't autoformat" checkbox, which switches a comment from casual HTML # to raw HTML. Deprecated in favor of prop_editor and named formats. No # longer included in the forms, but can potentially come in via Protocol. # - prop_editor # The markup format the comment's body text uses. See DW::Formats for more info. # - prop_admin_post # Whether this comment is from a community admin (and should thus be displayed # specially). # - subjecticon # The "subject icon" for this comment, chosen from a hardcoded list of ~32. # Only available in talkform. # - editreason # An explanation for the edit; only present if editing. # # Sort of like comment content, but not quite: # - unscreen_parent # Whether to unscreen the screened comment they're replying to. Only available # if this is a journal where the commenter can do that, and only shown in the # talkform. I think this is meant as a convenience for the no-javascript case, # because otherwise the AJAX unscreen button is faster and more intuitive. # # Identity stuff: # - usertype # The type of user submitting the comment. In the quick-reply, this is locked # to `cookieuser`. In the talkform, it's a group of radio buttons so you can # switch user/go anon, and the value will be one of the following: # - anonymous # - openid # - openid_cookie # - cookieuser # - user # All of the identity fields mostly get consumed by the Talk controller, but # they can also influence the initial state of the talkform if it gets # regenerated partway through a comment submission (when previewing or when # there's an error that needs fixing). These fields are documented more fully # down in authenticate_user_and_mutate_form. # - oidurl # - oiddo_login # - cookieuser # - userpost # - password # - do_login # # Hidden fields that determine what the comment is replying to: # - journal # The journal name for the entry they're replying to. # - itemid # The ditemid (obfuscated display ID) of the entry they're replying to. # See comments in the implementation of LJ::Talk::talkform for more info about # the different ID formats. # - parenttalkid # The jtalkid (raw ID) of the comment they're replying to. If they're replying # directly to the entry, this is 0. # - replyto # An exact duplicate of parenttalkid; # LJ::Talk::Post::prepare_and_validate_comment will fall back to this if # parenttalkid isn't present, which should never be the case. Nothing else # uses this, but jquery.quickreply.js confirms that it's present before # allowing you to continue. LJ::Comment->create omits replyto in the mock form # it sends to prep/validate. # - editid # The dtalkid (obfuscated display ID) of the existing comment to be edited. If # posting a new comment, this is 0. # # Consistency checks and metadata: # - lj_form_auth # Consistency check for CSRF prevention. Similar idea to chrp1, but checks more # stuff. See LJ::check_form_auth in Web.pm for more. # - chrp1 # A time-expiring server-provided token used to make spam posts inconvenient. # - captcha_type (and other captcha fields) # Which captcha implementation to use, if a captcha is deemed necessary; # absent if not. Captchas can bring in additional form fields. These get # consumed by the captcha implementations, which get invoked down near the # bottom of LJ::Talk::Post::prepare_and_validate_comment. # - qr # Hardcoded to 1 in the quick-reply form; absent in talkform. As far as I can # tell, nothing ever consumes this. Maybe was for some ancient server log # metrics? # # Things that affect the return link after replying: # - viewing_thread # The filtered thread view (`?thread=12345`) they were in when they hit the # "reply" link, and which they should be returned to once they finish posting. # Consumed by Talk controller to build the return link. # - style/format/s2id/fallback # The "viewing style" options (`?style=light`) that were in effect when they # hit the "reply" link, which should be re-instated once they finish # posting. You get one hidden input for each of these that was present, # although usually there's only one and the effects of mixing them seem # obscure. See LJ::viewing_style_opts (in Web.pm) for more info. # # Enablers for quick-reply's "more options" button: # - dtid # The dtalkid (obfuscated display ID) of the comment they're replying to (in # other words, the display version of `parenttalkid`). Only used by the # quick-reply JS for building the "more options" URL. Nothing on the backend # ever consumes this. # - basepath # The path to the journal entry they're replying to, with any viewing style # options included. The quick-reply JS uses this for building the "more # options" URL. Nothing on the backend ever consumes this. # # Submit buttons and their friends: # - submitpost # The post button. Nothing on the backend listens for this by name; it's just # the button that does a "vanilla" form submission. # - submitpreview # The actual signal to the backend that we need to build a preview for this # comment instead of posting it. Has indirect behavior sometimes because we # disable the post buttons with JS to prevent double-submits, and browsers # don't send disabled inputs. In the quick-reply, this is a hidden input whose # value gets set to 1 if they click `submitpview`. In the talkform, this is # the name of the preview button, so it gets sent normally if JS is disabled, # but see also `previewplaceholder`. # - submitpview # The "preview" submit button in the quick-reply form. Nothing on the backend # listens for this by name; it just sets the value of the hidden # `submitpreview` input. # - previewplaceholder # A hidden input in the talkform whose value is 1. If JS is enabled, we change # its name to `submitpreview` when they click the preview button, so the # signal to preview will reach the backend despite the submit buttons being # disabled. # - submitmoreopts # The "more options" button in the quick-reply. Changes the action of the form # to point to the ReplyPage instead of to talkpost_do (so they can continue # with a partially-filled talkform), then submits. Nothing on the backend # listens for this by name. sub talkpost_do_handler { # This route handles form_auth manually; we want to get the post args, fail # gently, regenerate the comment form, and not lose your text. my ( $ok, $rv ) = controller( form_auth => 0, anonymous => 1 ); return $rv unless $ok; my $r = $rv->{r}; my $POST = $r->post_args; my $remote = $rv->{remote}; my $GET = $r->get_args; my $title = '.success.title'; my $vars; # Like error_ml but for when we don't control the error string. my $err_raw = sub { return DW::Template->render_template( 'error.tt', { message => $_[0] } ); }; # For errors that aren't immediately fatal, collect them as we go and let # the user fix them all at once. my @errors; # Track form_auth specially, since we want to bail on some things if it fails. my $form_auth_ok = 0; # First, make sure we've got POST data and confirm that it's from a legit # local form (and not some CSRF shenanigans). Usually that's a real POST, # but sometimes it's a saved POST that we set aside while an OpenID # commenter sorted out their identity. if ( $r->did_post ) { $form_auth_ok = LJ::check_form_auth( $POST->{lj_form_auth} ); push @errors, LJ::Lang::ml('/talkpost_do.tt.error.invalidform') unless $form_auth_ok; } elsif ( my $mode = $GET->{'openid.mode'} ) { # If they're coming back from an OpenID identity server, restore the # POST hash we saved before they left. unless ( $GET->{jid} && $GET->{pendcid} && ( $mode eq 'id_res' || $mode eq 'cancel' ) ) { return error_ml('/talkpost_do.tt.error.badrequest'); } # We only check form_auth for real POSTs; if they're coming back from # OpenID, we already checked earlier. $form_auth_ok = 1; my $csr = LJ::OpenID::consumer( $GET->mixed ); if ( $mode eq 'id_res' ) { # Verify their identity unless ( LJ::check_referer( '/talkpost_do', $GET->{'openid.return_to'} ) ) { return error_ml( '/openid/login.tt.error.invalidparameter', { item => "return_to" } ); } my $errmsg; my $uo = LJ::User::load_from_consumer( $csr, \$errmsg ); return $err_raw->($errmsg) unless $uo; # Change who we think we are. NB: don't use set_remote to ACTUALLY # change the remote, or you'll cause a glitch in the matrix. We just # want to use this OpenID user below to check auth, etc. $remote = $uo; } # Restore their data to reset state where they were my $pendcid = $GET->{pendcid} + 0; my $journalu = LJ::load_userid( $GET->{jid} ); return error_ml("/talkpost_do.tt.error.openid.nodb") unless $journalu && $journalu->writer; my $pending = $journalu->selectrow_array( "SELECT data FROM pendcomments WHERE jid=? AND pendcid=?", undef, $journalu->{userid}, $pendcid ); return error_ml("/talkpost_do.tt.error.openid.nopending") unless $pending; my $penddata = eval { Storable::thaw($pending) }; $POST = $penddata; # Not fatal, maybe just decided to be someone else for this comment: push @errors, "You chose to cancel your identity verification" if $csr->user_cancel; } else { # You didn't post, you aren't coming back from OpenID, wyd?? return error_ml('/talkpost_do.tt.error.badrequest'); } # We don't call LJ::text_in() here; instead, we call it during # LJ::Talk::Post::prepare_and_validate_comment. # Old talkpost_do.bml comments said they did this because of non-UTF-8 in # "replies coming from mail clients," but nobody in 2020 knew what that # meant. (Maybe they just wanted to call it only once, so they put it in a # spot where it would also hit replies that come through LJ::Protocol # instead of talkpost_do? But you'd think encoding checks should be the # concern of the request handler, whether it's Protocol or a controller.) # Anyway... the point is it gets called eventually. -NF my $journalu = LJ::load_user( $POST->{journal} ); return error_ml('/talkpost_do.tt.error.nojournal') unless $journalu; # This launches some garbage into the void of the Apache "notes" system, and # it's impossible to know for sure what ends up reading it as an implicit # argument. Obviously everyone hates this. It dates back to the old # talkpost_do.bml we inherited from LJ. NF's best guess is that S2.pm # expects this, but it might also be irrelevant. Who knows. $r->note( 'journalid', $journalu->userid ) if $r; unless ( $POST->{itemid} ) { return error_ml('talk.error.noentry'); } my $entry = LJ::Entry->new( $journalu, ditemid => $POST->{itemid} + 0 ); my $talkurl = $entry->url; # validate the challenge/response value (anti-spammer) my ( $chrp_ok, $chrp_err ) = LJ::Talk::validate_chrp1( $POST->{'chrp1'} ); unless ($chrp_ok) { if ($LJ::REQUIRE_TALKHASH) { push @errors, "Sorry, form expired. Please re-submit." if $chrp_err eq "too_old"; push @errors, "Missing parameters"; } } ## Sort out who's posting for real. my ( $commenter, $didlogin, $authok, $auth ); if ($form_auth_ok) { ( $authok, $auth ) = authenticate_user_and_mutate_form( $POST, $remote, $journalu ); if ($authok) { if ( $auth->{check_url} ) { # openid thing. Round and round we go. return $r->redirect( $auth->{check_url} ); } else { $commenter = $auth->{user}; $didlogin = $auth->{didlogin}; } } else { push @errors, $auth; } } # Now that we've given them a chance to log in, set a resource group. # FIXME: You're supposed to set this in the template, so if someone ever # rewrites DW::Captcha::TextCAPTCHA to stop snooping around in # $LJ::ACTIVE_RES_GROUP, definitely do that. In the meantime, this needs to # happen before LJ::Talk::talkform gets called or things might break. -NF my $real_remote = LJ::get_remote(); if ( !LJ::BetaFeatures->user_in_beta( $real_remote => "nos2foundation" ) ) { LJ::set_active_resource_group("foundation"); } else { LJ::set_active_resource_group("jquery"); } my $need_captcha = 0; my $comment; if ($form_auth_ok) { # Prepare the comment (or wipe out on the permissions/consistency checks) $comment = LJ::Talk::Post::prepare_and_validate_comment( $POST, $commenter, $entry, \$need_captcha, \@errors ); } # If there's anything in @errors at the end of prepare_and_validate_comment, # it returns undef instead of a $comment, even if it wasn't the one to # add those errors. So all the "non-fatal" errors we've been logging above # become suddenly fatal right... about... NOW. # At this point, there's three main paths: # 1. Stop and show the requested preview (and report errors inline) # 2. Stop and ask user to fix errors # 3. Continue and post the comment for real. # 1. We're previewing! # For most errors, we preview anyway; they can fix it while they edit # their text. But we DO need to know who they think they are, so let the # error path handle auth failures. if ( $authok && $POST->{submitpreview} ) { # yer a reply page, Harry. (keep consistent behavior by loading same # JS/CSS as journal pages.) LJ::Talk::init_s2journal_js( iconbrowser => 1, siteskin => 1, noqr => 1 ); # Plus we're displaying entry/comment content, so the legacy site skins # need CSS for that. LJ::need_res( { group => 'jquery' }, 'stc/siteviews/layout.css', 'stc/entrypage.css', ); # If validation failed, just use what we have on hand to build the preview. # Might theoretically have borked non-UTF-8, shrug. unless ($comment) { $comment = { u => $commenter, entry => $entry, parent => { talkid => $POST->{replyto} || $POST->{parenttalkid}, }, subject => $POST->{subject}, body => $POST->{body}, subjecticon => $POST->{subjecticon} eq 'none' ? '' : $POST->{subjecticon}, preformat => $POST->{'prop_opt_preformatted'}, admin_post => $POST->{'prop_admin_post'}, picture_keyword => $POST->{'prop_picture_keyword'}, editor => $POST->{'prop_editor'}, }; } my $talkform = LJ::Talk::talkform( { journalu => $journalu, parpost => $comment->{parent}, replyto => $comment->{parent}->{talkid}, ditemid => $comment->{entry}->ditemid, do_captcha => $need_captcha, errors => @errors ? \@errors : undef, form => $POST, } ); $vars->{title} = '.title.preview'; $vars->{preview} = 1; $vars->{comment} = preview_comment_args($comment); $vars->{parent} = preview_parent_args($comment); $vars->{html} = $talkform; return DW::Template->render_template( 'talkpost_do.tt', $vars ); } # 2. Validation failed! # Don't continue; report errors, ask for help, and regenerate the # form. We repopulate what we can via hidden fields, but the objects # (journalu & parpost) must be recreated here. unless ($comment) { my ( $sth, $parpost ); my $dbcr = LJ::get_cluster_def_reader($journalu); return error_ml('/talkpost_do.tt.error.nodb') unless $dbcr; $sth = $dbcr->prepare( "SELECT posterid, state FROM talk2 " . "WHERE journalid=? AND jtalkid=?" ); $sth->execute( $journalu->{userid}, $POST->{itemid} + 0 ); $parpost = $sth->fetchrow_hashref; # yer a reply page, Harry. (keep consistent behavior by loading same # JS/CSS as journal pages.) LJ::Talk::init_s2journal_js( iconbrowser => 1, siteskin => 1, noqr => 1 ); my $talkform = LJ::Talk::talkform( { journalu => $journalu, parpost => $parpost, replyto => $POST->{replyto} || $POST->{parenttalkid}, ditemid => $POST->{itemid}, do_captcha => $need_captcha, errors => \@errors, form => $POST, } ); $vars->{title} = '.title.error'; $vars->{html} = $talkform; return DW::Template->render_template( 'talkpost_do.tt', $vars ); } # 3. It's go time!! # We might show a page at the end anyway (for example, if the user logged # in), but the most common path is to silently redirect back to the thread # after posting. my $parent = $comment->{parent}; my $unscreen_parent = $POST->{unscreen_parent} ? 1 : 0; # ACTUALLY POST IT my $editid = $POST->{editid}; my $wasscreened = ( $parent->{state} eq 'S' ); my $talkid; # check for spam domains LJ::Hooks::run_hooks( 'spam_check', $comment->{u}, $comment, 'comment' ); if ($editid) { my ( $postok, $talkid_or_err ) = LJ::Talk::Post::edit_comment($comment); unless ($postok) { return $err_raw->($talkid_or_err); } $talkid = $talkid_or_err; } else { my ( $postok, $talkid_or_err ) = LJ::Talk::Post::post_comment( $comment, $unscreen_parent ); unless ($postok) { return $err_raw->($talkid_or_err); } $talkid = $talkid_or_err; } # Yeah, we're done. my $dtalkid = $talkid * 256 + $entry->{anum}; # Figure out whether we should offer to update their default formatting. my $editor_new; if ( $real_remote && DW::Formats::is_active( $comment->{editor} ) && $comment->{editor} ne $real_remote->comment_editor ) { $editor_new = $comment->{editor}; } # Allow style=mine, etc for QR redirects my $style_args = LJ::viewing_style_args(%$POST); # FIXME: potentially can be replaced with some form of additional logic when we have multiple account linkage my $posted = $comment->{state} eq 'A' ? "posted=1" : ""; my $cthread = $POST->{'viewing_thread'} ? "thread=$POST->{viewing_thread}" : "view=$dtalkid"; my $commentlink = LJ::Talk::talkargs( $talkurl, $cthread, $style_args, $posted ) . LJ::Talk::comment_anchor($dtalkid); my $mlcode; if ( $comment->{state} eq 'A' ) { # Redirect straight to the post as long as: # - it isn't screened # - it didn't unscreen its parent # - its formatting type didn't change # - it didn't log the user in as a side-effect if ( !( $wasscreened && ( $parent->{state} ne 'S' ) ) && !$didlogin && !$editor_new ) { LJ::set_lastcomment( $journalu->id, $commenter, $dtalkid ); return $r->redirect($commentlink); } $mlcode = '.success.message2'; } else { # otherwise, it's a screened comment. if ( $journalu && $journalu->is_community ) { if ( $POST->{usertype} eq 'anonymous' ) { $mlcode = '.success.screened.comm.anon3'; } elsif ( $commenter && $commenter->can_manage($journalu) ) { $mlcode = '.success.screened.comm.owncomm4'; } else { $mlcode = '.success.screened.comm3'; } } else { # not a community if ( $POST->{usertype} eq 'anonymous' ) { $mlcode = '.success.screened.user.anon3'; } elsif ( $commenter && $commenter->equals($journalu) ) { $mlcode = '.success.screened.user.ownjournal3'; } else { $mlcode = '.success.screened.user3'; } } } $vars->{title} = $title; my @notices = ( LJ::Lang::ml( "/talkpost_do.tt$mlcode", { aopts => "href='$commentlink'" } ) ); push @notices, DW::Template->template_string( 'default_editor_form.tt', { type => 'comment', format => $DW::Formats::formats{$editor_new}, exit_text => "Return to comment", exit_url => $commentlink, } ) if $editor_new; push @notices, LJ::Lang::ml('/talkpost_do.tt.success.unscreened') if $wasscreened && ( $parent->{state} ne 'S' ); push @notices, LJ::Lang::ml('/talkpost_do.tt.success.loggedin') if $didlogin; $vars->{html} = join( "\n", map { "
$_
" } @notices ); return DW::Template->render_template( 'talkpost_do.tt', $vars ); } # Handles user auth for the talkform's "from" fields. # Args: # - $form: a hashref representing the POSTed comment form. We might mutate its # usertype, userpost, cookieuser, and oidurl fields, in order to canonicalize # some values or help the comment form react to a change in the global login # state. # - $remote: the current logged-in LJ::User, or undef, OR the just-now # authenticated OpenID user (which is why we can't just get_remote from within). # - $journalu: LJ::User who owns the journal the comment was submitted to. Need # this for storing pending comments in first pass through openid auth, and also # we use it to switch off between variant error messages. # Returns: (1, result) on success, (0, error) on failure. result is one of: # - {user => $u, didlogin => $bool} ($u is undef for anons) # - {check_url => $url} (openid redirect) sub authenticate_user_and_mutate_form { my ( $form, $remote, $journalu ) = @_; my $didlogin = 0; my $err = sub { my $error = shift; return ( 0, $error ); }; my $mlerr = sub { return $err->( LJ::Lang::ml(@_) ); }; my $incoherent = sub { return $mlerr->("/talkpost_do.tt.error.confused_identity"); }; my $got_user = sub { my $user = shift; return ( 1, { user => $user, didlogin => $didlogin } ); }; # The "usertype" field must be one of the following. (Each value might have # some associated fields it expects, which are shown as nested lists.) # - anonymous # - (nothing) # - openid # - oidurl # - oiddo_login # - openid_cookie # - (nothing) (in talkform), OR: # - cookieuser (= ext_1234) (in quickreply) # - cookieuser (currently logged in user) # - cookieuser (= username) (yes, "cookieuser" is the field's name) # - user (non-logged-in user, w/ name/password provided) # - userpost (the username provided in the form) # - password # - do_login # CHECKLIST: # 1. Check for incoherent combinations of fields. (Most can only happen with # JS disabled. I'm told there were once cases where it could post as the # wrong user, possibly without auth; who knows. But regardless, conflicting # info means the user's intention was not clear and they must clarify.) # 2. Validate the specified user type's credentials. # 3. If validated, return the relevant user object. # 4. OpenID is weird. # NOTA BENE: This long "if" statement is tedious and stupid, and I'm well # aware there's several cleverer and more exciting ways to write it. But # don't. KEEP IT STUPID. KEEP IT SAFE. -NF, 2020 if ( $form->{usertype} eq 'anonymous' ) { if ( $form->{oidurl} || $form->{userpost} ) { return $incoherent->(); } return $got_user->(undef); # Well! that was easy. } elsif ( $form->{usertype} eq 'cookieuser' ) { if ( $form->{oidurl} ) { return $incoherent->(); } # If they selected "current user" and then typed in their own username, # well, that's "wrong" but their intention was perfectly clear. But if # they typed in a DIFFERENT username, get outta here. if ( $form->{userpost} && ( $form->{userpost} ne $form->{cookieuser} ) ) { return $incoherent->(); } # OK! Check if that's the logged-in user. if ( $remote && ( $remote->user eq $form->{cookieuser} ) ) { return $got_user->($remote); # Cool. } else { return $mlerr->("/talkpost_do.tt.error.lostcookie"); } } elsif ( $form->{usertype} eq 'user' ) { if ( $form->{oidurl} ) { return $incoherent->(); } # No username? if ( !$form->{userpost} ) { my $iscomm = $journalu->is_community ? '.comm' : ''; my $noanon = $journalu->prop('opt_whocanreply') eq 'all' ? '' : '.noanon'; return $mlerr->( "/talkpost_do.tt.error.nousername$noanon$iscomm", { sitename => $LJ::SITENAMESHORT } ); } my $exptype; # set to long if ! after username my $ipfixed; # set to remote ip if < after username # Parse inline login options. # MUTATE FORM: remove trailing garbage from username. if ( $form->{userpost} =~ s/([!<]{1,2})$// ) { $exptype = 'long' if index( $1, "!" ) >= 0; $ipfixed = LJ::get_remote_ip() if index( $1, "<" ) >= 0; } my $up = LJ::load_user( $form->{userpost} ); # Now for all the things that can go wrong: if ( !$up ) { return $mlerr->( "/talkpost_do.tt.error.badusername2", { sitename => $LJ::SITENAMESHORT, aopts => "href='$LJ::SITEROOT/lostinfo'" } ); } if ( $up->is_identity ) { return $err->( "To comment as an OpenID user, you must choose the " . "OpenID option and authenticate with your identity provider; " . "it's not possible to log in using an OpenID account's " . "internal 'ext_12345' username." ); } if ( $up->is_community || $up->is_syndicated ) { return $mlerr->("/talkpost_do.tt.error.postshared"); } # authenticate on username/password my $ok = LJ::auth_okay( $up, $form->{password} ); unless ($ok) { # Don't pre-populate the fix-up form with a password we already know is wrong. $form->{password} = ''; return $mlerr->( "/talkpost_do.tt.error.badpassword2", { aopts => "href='$LJ::SITEROOT/lostinfo'" } ); } # GREAT, they're in! # if the user chooses to log in, do so if ( $form->{do_login} ) { $didlogin = $up->make_login_session( $exptype, $ipfixed ); # MUTATE FORM: change the usertype, so if they need to fix an # unrelated error and are already logged in, the form uses the # "currently logged-in user" option. $form->{usertype} = 'cookieuser'; $form->{cookieuser} = $up->user; } return $got_user->($up); } elsif ( $form->{usertype} eq 'openid' || $form->{usertype} eq 'openid_cookie' ) { if ( $form->{userpost} ) { return $incoherent->(); } # Okay: This one's weird, but mostly just because the code order is # backwards from how things happen irl, WHICH IS: # - Person supplies OpenID URL. # - We store the form to the database, bail out, and tell the caller to # redirect to an authentication server URL. (The URL also tells the auth # server where to redirect to once IT'S done.) # - Auth server sends them back to /talkpost_do, but as a GET request # instead of a POST. # - Controller restores their frozen POST data from last time and calls # this function again, passing the newly authenticated user as $remote. # (This is why we're not using LJ::get_remote in this function, btw.) # - Since $remote is set, we let them through. # If $remote looks good, they're in. if ( $remote && defined $remote->openid_identity ) { # Go ahead and log in, if requested. if ( $form->{oiddo_login} ) { # Those extra form vars got stored last time, see below. $didlogin = $remote->make_login_session( $form->{exptype}, $form->{ipfixed} ); # MUTATE FORM: change the usertype if they logged in, so # things look more consistent if they hit an unrelated error $form->{usertype} = 'openid_cookie'; } return $got_user->($remote); # welcome back } else { # If this is your first time at Tautology Club... you've never been # here before. return $err->("No OpenID identity URL entered") unless $form->{oidurl}; my $csr = LJ::OpenID::consumer(); my $exptype = 'short'; my $ipfixed = 0; # parse inline login opts # MUTATE FORM: remove trailing garbage from oidurl if ( $form->{oidurl} =~ s/([!<]{1,2})$// ) { $exptype = 'long' if index( $1, "!" ) >= 0; $ipfixed = LJ::get_remote_ip() if index( $1, "<" ) >= 0; } my $tried_local_ref = LJ::OpenID::blocked_hosts($csr); my $claimed_id = $csr->claimed_identity( $form->{oidurl} ); unless ($claimed_id) { return $err->( "You can't use a $LJ::SITENAMESHORT OpenID account on $LJ::SITENAME — " . "just go login with your actual $LJ::SITENAMESHORT account." ) if $$tried_local_ref; return $err->( "No claimed id: " . $csr->err ); } # Store their cleaned up identity url (vs. what they actually typed.) # MUTATE FORM: canonicalize oidurl $form->{oidurl} = $claimed_id->claimed_url(); # Store the entry my $pendcid = LJ::alloc_user_counter( $journalu, "C" ); return $err->("Unable to allocate pending id") unless $pendcid; # persist login options in the form data, since we removed them from # the oidurl # MUTATE FORM: add junk that never appears in a real comment form. $form->{exptype} = $exptype; $form->{ipfixed} = $ipfixed; my $penddata = Storable::freeze($form); return $err->("Unable to get database handle to store pending comment") unless $journalu->writer; my $journalid = $journalu->id; $journalu->do( "INSERT INTO pendcomments (jid, pendcid, data, datesubmit) VALUES (?, ?, ?, UNIX_TIMESTAMP())", undef, $journalid, $pendcid, $penddata ); return $err->( $journalu->errstr ) if $journalu->err; my $check_url = $claimed_id->check_url( return_to => "$LJ::SITEROOT/talkpost_do?jid=$journalid&pendcid=$pendcid", trust_root => "$LJ::SITEROOT", delayed_return => 1, ); # Caller must redirect to this URL. return ( 1, { check_url => $check_url } ); } } else { return $err->("Reply form was submitted without any user information."); } } # Returns hashref for template. sub preview_comment_args { my ($comment) = @_; my $cleansubject = $comment->{subject}; LJ::CleanHTML::clean_subject( \$cleansubject ); my $cleanbody = $comment->{body}; LJ::CleanHTML::clean_comment( \$cleanbody, { anon_comment => LJ::Talk::treat_as_anon( $comment->{u}, $comment->{entry}->journal ), preformatted => $comment->{preformat}, admin_post => $comment->{admin_post}, editor => $comment->{editor}, } ); my $poster = "(Anonymous)"; my $icon = ''; if ( $comment->{u} ) { $poster = $comment->{u}->ljuser_display; my $userpic = LJ::Userpic->new_from_keyword( $comment->{u}, $comment->{picture_keyword} ); if ($userpic) { $icon = '' . $userpic->imgtag( keyword => $comment->{prop_picture_keyword} ) . ''; } } my $preview = { poster => $poster, subjecticon => LJ::Talk::print_subjecticon_by_id( $comment->{subjecticon} ), body => $cleanbody, subject => $cleansubject, icon => $icon, admin_post => $comment->{admin_post}, }; return $preview; } # Returns hashref for template. sub preview_parent_args { my ($comment) = @_; my $userpic_tag = sub { my $item = shift; my $icon = ''; my $userpic = $item->userpic; if ($userpic) { $icon = $userpic->imgtag( keyword => $item->userpic_kw ); } return $icon; }; my $entry = $comment->{entry}; if ( $comment->{parent}->{talkid} ) { # Replying to comment my $parentitem = LJ::Comment->new( $entry->journal, jtalkid => $comment->{parent}->{talkid} ); my $poster = 'Anonymous'; my $poster_name = ''; my $in_journal = $entry->journal->ljuser_display; if ( $parentitem->poster ) { $poster = $parentitem->poster->ljuser_display; $poster_name = $parentitem->poster->name_html; if ( $parentitem->poster->equals( $entry->journal ) ) { $in_journal = ''; } } return { type => 'comment', body => $parentitem->body_html, subject => $parentitem->subject_html, poster => $poster, poster_name => $poster_name, in_journal => $in_journal, admin_post => $parentitem->prop('admin_post'), icon => $userpic_tag->($parentitem), time => $parentitem->{datepost}, url => $parentitem->url, entry_url => $entry->url, }; } else { # Replying to entry my $in_journal = $entry->poster->equals( $entry->journal ) ? '' : $entry->journal->ljuser_display; return { type => 'entry', body => $entry->event_html, subject => $entry->subject_html, poster => $entry->poster->ljuser_display, poster_name => $entry->poster->name_html, in_journal => $in_journal, icon => $userpic_tag->($entry), time => $entry->eventtime_mysql, url => $entry->url, entry_url => $entry->url, }; } } sub talkmulti_handler { my ( $ok, $rv ) = controller( form_auth => 0, anonymous => 0 ); return $rv unless $ok; my $r = $rv->{r}; my $POST = $r->post_args; my $remote = $rv->{remote}; my $GET = $r->get_args; my $title; my $vars; my $mode = $POST->{'mode'}; if ( $mode eq 'screen' ) { $title = '.title.screen'; } elsif ( $mode eq 'unscreen' ) { $title = '.title.unscreen'; } elsif ( $mode eq 'delete' || $mode eq 'deletespam' ) { $title = '.title.delete'; } else { return error_ml('/talkmulti.tt.error.invalid_mode'); } my $sth; return error_ml("bml.requirepost") unless $r->did_post; my @talkids; foreach ( keys %{$POST} ) { push @talkids, $1 if /^selected_(\d+)$/; } return error_ml('/talkmulti.tt.error.none_selected') unless @talkids; my $u = LJ::load_user( $POST->{'journal'} ); return error_ml('talk.error.bogusargs') unless $u && $u->{'clusterid'}; return error_ml($LJ::MSG_READONLY_USER) if $u->is_readonly; my $dbcr = LJ::get_cluster_def_reader($u); my $jid = $u->userid; my $ditemid = $POST->{'ditemid'} + 0; my $e = LJ::Entry->new( $u, ditemid => $ditemid ); my $commentlink = $e->url; my $itemid = $ditemid >> 8; my $log = $dbcr->selectrow_hashref( "SELECT * FROM log2 WHERE journalid=? AND jitemid=?", undef, $jid, $itemid ); return error_ml('/talkmulti.tt.error.inconsistent_data') unless $log && $log->{'anum'} == ( $ditemid & 255 ); my $up = LJ::load_userid( $log->{'posterid'} ); # check permissions return error_ml('/talkmulti.tt.error.privs.screen') if $mode eq "screen" && !LJ::Talk::can_screen( $remote, $u, $up ); return error_ml('/talkmulti.tt.error.privs.unscreen') if $mode eq "unscreen" && !LJ::Talk::can_unscreen( $remote, $u, $up ); return error_ml('/talkmulti.tt.error.privs.delete') if ( $mode eq "delete" || $mode eq 'deletespam' ) && !LJ::Talk::can_delete( $remote, $u, $up ); # filter our talkids down to those that are actually attached to the log2 # specified. also, learn the state and poster of all the items. my $in = join( ',', @talkids ); $sth = $dbcr->prepare( "SELECT jtalkid, state, posterid FROM talk2 " . "WHERE journalid=? AND jtalkid IN ($in) " . "AND nodetype='L' AND nodeid=?" ); $sth->execute( $jid, $itemid ); my %talkinfo; while ( my ( $id, $state, $posterid ) = $sth->fetchrow_array ) { $talkinfo{$id} = [ $state, $posterid ]; } @talkids = keys %talkinfo; # do the work: if ( $mode eq "delete" || $mode eq 'deletespam' ) { # first, unscreen everything for replycount and hasscreened to adjust my @unscreen = grep { $talkinfo{$_}->[0] eq "S" } @talkids; LJ::Talk::unscreen_comment( $u, $itemid, @unscreen ); # FIXME: race condition here... somebody could get lucky and view items while unscreened. # then delete, updating the log2 replycount as necessary # Mark as spam if ( $mode eq 'deletespam' && !LJ::sysban_check( 'spamreport', $u->user ) ) { # don't let $remote mark their own comments as spam foreach ( grep { $talkinfo{$_}->[1] != $remote->id } @talkids ) { my $s = LJ::Talk::mark_comment_as_spam( $u, $_ ); } } my $num = LJ::delete_comments( $u, "L", $itemid, @talkids ); LJ::replycount_do( $u, $itemid, "decr", $num ); LJ::Talk::update_commentalter( $u, $itemid ); $vars = { title => '.deleted.title', body => '.deleted.body2', 'aopts' => "href='$commentlink'" }; } elsif ( $mode eq "unscreen" ) { LJ::Talk::unscreen_comment( $u, $itemid, @talkids ); $vars = { title => '.unscreened.title', body => '.unscreened.body2', 'aopts' => "href='$commentlink'" }; } elsif ( $mode eq "screen" ) { LJ::Talk::screen_comment( $u, $itemid, @talkids ); $vars = { title => '.screened.title', body => '.screened.body2', 'aopts' => "href='$commentlink'" }; } return DW::Template->render_template( 'talkmulti.tt', $vars ); } sub talkscreen_handler { my ( $ok, $rv ) = controller( form_auth => 1, anonymous => 0 ); return $rv unless $ok; my $r = $rv->{r}; my $POST = $r->post_args; my $remote = $rv->{remote}; my $GET = $r->get_args; my $jsmode = !!$GET->{jsmode}; my $error = sub { if ($jsmode) { # FIXME: remove once we've switched over completely to jquery if ( !!$GET->{json} ) { $r->print( to_json( { error => LJ::Lang::ml( $_[0] ) } ) ); return $r->OK; } else { return "alert('" . LJ::ejs( LJ::Lang::ml( $_[0] ) ) . "'); 0;"; } } return error_ml( $_[0] ); }; my $mode = $POST->{'mode'} || $GET->{'mode'}; my $talkid = $POST->{'talkid'} || $GET->{'talkid'}; my $journal = $POST->{'journal'} || $GET->{'journal'}; my $qtalkid = $talkid + 0; my $dtalkid = $qtalkid; # display talkid, for use in URL later my $jsres = sub { my ( $mode, $message ) = @_; # flip case of 'un' my $newmode = "un$mode"; $newmode =~ s/^unun//; my $alttext = $newmode; $alttext =~ s/(\w+)/\u\L$1/g; my $stockimg = { 'screen' => "silk/comments/screen.png", 'unscreen' => "silk/comments/unscreen.png", 'freeze' => "silk/comments/freeze.png", 'unfreeze' => "silk/comments/unfreeze.png", }; my $imgprefix = $LJ::IMGPREFIX; $imgprefix =~ s/^https?://; my $ret = { id => $dtalkid, mode => $mode, newalt => $alttext, oldimage => "$imgprefix/$stockimg->{$mode}", newimage => "$imgprefix/$stockimg->{$newmode}", newurl => "$LJ::SITEROOT/talkscreen?mode=$newmode&journal=$journal&talkid=$dtalkid", msg => LJ::Lang::ml($message), }; sleep 1 if $LJ::IS_DEV_SERVER; $r->print( to_json($ret) ); return $r->OK; }; # we need to find out: $u, $up (poster of the entry this is a comment to), # userpost (username of this comment's author). Then we can check permissions. my $u = LJ::load_user($journal); return $error->('talk.error.bogusargs') unless $u; # if we're on a user vhost, our remote was authed using that vhost, # so let's let them only modify the journal that their session # was authed against. if they're on www., then their javascript is # off/old, and they get a confirmation page, and we're using their # mastersesion cookie anyway. my $domain_owner = LJ::Session->url_owner; if ($domain_owner) { return $error->('talk.error.bad_owner') unless $domain_owner eq $u->{user}; } my $dbcr = LJ::get_cluster_def_reader($u); return $error->('error.nodb') unless $dbcr; my $post; $qtalkid = int( $qtalkid / 256 ); # get rid of anum $post = $dbcr->selectrow_hashref( "SELECT jtalkid AS 'talkid', nodetype, state, nodeid AS 'itemid', " . "parenttalkid, journalid, posterid FROM talk2 " . "WHERE journalid=$u->{'userid'} AND jtalkid=$qtalkid" ); return $error->('talk.error.nocomment') unless $post; return $error->('talk.error.comm_deleted') if $post->{'state'} eq "D"; my $state = $post->{'state'}; $u ||= LJ::load_userid( $post->{'journalid'} ); return $error->($LJ::MSG_READONLY_USER) if $u->is_readonly; if ( $post->{'posterid'} ) { $post->{'userpost'} = LJ::get_username( $post->{'posterid'} ); } my $qitemid = $post->{'itemid'} + 0; my $e = LJ::Entry->new( $u, jitemid => $qitemid ); my $up = $e->poster; my $itemlink = $e->url; my $commentlink = LJ::Talk::talkargs( $itemlink, "view=" . $talkid, "", "" ) . LJ::Talk::comment_anchor($talkid); my $vars = { itemlink => $itemlink, commentlink => $commentlink, mode => $mode, talkid => $talkid, u => $u }; if ( $mode eq 'screen' ) { my $can_screen = LJ::Talk::can_screen( $remote, $u, $up, $post->{'userpost'} ); return $error->('/talkscreen.tt.error.privs.screen') unless $can_screen; if ( $POST->{'confirm'} eq 'Y' ) { if ( $state ne 'S' ) { LJ::Talk::screen_comment( $u, $qitemid, $qtalkid ); } # FIXME: no error checking? return $jsres->( $mode, '/talkscreen.tt.screened.body' ) if $jsmode; $vars->{done} = 1; $vars->{action} = 'screened'; } } elsif ( $mode eq 'unscreen' ) { my $can_unscreen = LJ::Talk::can_unscreen( $remote, $u, $up, $post->{'userpost'} ); return $error->('/talkscreen.tt.error.privs.unscreen') unless $can_unscreen; if ( $POST->{'confirm'} eq 'Y' ) { if ( $state ne 'A' ) { LJ::Talk::unscreen_comment( $u, $qitemid, $qtalkid ); } # FIXME: no error checking? return $jsres->( $mode, '/talkscreen.tt.unscreened.body' ) if $jsmode; $vars->{done} = 1; $vars->{action} = 'unscreened'; } } elsif ( $mode eq 'freeze' ) { my $can_freeze = LJ::Talk::can_freeze( $remote, $u, $up, $post->{userpost} ); return $error->('/talkscreen.tt.error.privs.freeze') unless $can_freeze; if ( $POST->{confirm} eq 'Y' ) { if ( $state ne 'F' ) { LJ::Talk::freeze_thread( $u, $qitemid, $qtalkid ); } return $jsres->( $mode, '/talkscreen.tt.frozen.body' ) if $jsmode; $vars->{done} = 1; $vars->{action} = 'frozen'; } } elsif ( $mode eq 'unfreeze' ) { my $can_unfreeze = LJ::Talk::can_unfreeze( $remote, $u, $up, $post->{userpost} ); return $error->("You are not allowed to unfreeze this thread") unless $can_unfreeze; if ( $POST->{confirm} eq 'Y' ) { if ( $state eq 'F' ) { LJ::Talk::unfreeze_thread( $u, $qitemid, $qtalkid ); } return $jsres->( $mode, '/talkscreen.tt.unfrozen.body' ) if $jsmode; $vars->{done} = 1; $vars->{action} = 'unfrozen'; } } else { return error_ml('error.unknownmode'); } return DW::Template->render_template( 'talkscreen.tt', $vars ); } sub delcomment_handler { my ( $ok, $rv ) = controller( form_auth => 1, anonymous => 0 ); return $rv unless $ok; my $r = $rv->{r}; my $POST = $r->post_args; my $remote = $rv->{remote}; my $GET = $r->get_args; my $vars; my $jsmode = !!$GET->{jsmode}; my $error = sub { if ($jsmode) { # FIXME: remove once we've switched over completely to jquery if ( !!$GET->{json} ) { sleep 1 if $LJ::IS_DEV_SERVER; $r->print( to_json( { error => LJ::Lang::ml( $_[0] ) } ) ); return $r->OK; } else { return "alert('" . LJ::ejs( LJ::Lang::ml( $_[0] ) ) . "'); 0;"; } } return error_ml( $_[0] ); }; my $bad_input = sub { return $error->( "Bad input: " . LJ::Lang::ml( $_[0] ) ) if $jsmode; return error_ml( $_[0] ); }; return $error->( LJ::error_noremote() ) unless $remote; return $error->('talk.error.bogusargs') unless $GET->{'journal'} ne "" && $GET->{'id'}; # $u is user object of journal that owns the talkpost my $u = LJ::load_user( $GET->{'journal'} ); return $bad_input->('error.nojournal') unless $u; # find out whether $u is a community. We'll use this to refer to ML strings # later my $iscomm = $u->is_community ? '.comm' : ''; # if we're on a user vhost, our remote was authed using that vhost, # so let's let them only modify the journal that their session # was authed against. if they're on www., then their javascript is # off/old, and they get a confirmation page, and we're using their # mastersesion cookie anyway. my $domain_owner = LJ::Session->url_owner; if ($domain_owner) { return $bad_input->('talk.error.bad_owner') unless $domain_owner eq $u->{user}; } # can't delete if you're suspended return $bad_input->('/delcomment.tt.error.suspended') if $remote->is_suspended; return $error->($LJ::MSG_READONLY_USER) if $u->is_readonly; my $dbcr = LJ::get_cluster_def_reader($u); return $error->('error.nodb') unless $dbcr; # $tp is a hashref of info about this individual talkpost row my $tpid = $GET->{'id'} >> 8; my $tp = $dbcr->selectrow_hashref( "SELECT jtalkid AS 'talkid', nodetype, state, " . "nodeid AS 'itemid', parenttalkid, journalid, posterid " . "FROM talk2 " . "WHERE journalid=? AND jtalkid=?", undef, $u->userid, $tpid ); return $bad_input->('/delcomment.tt.error.nocomment') unless $tp; return $bad_input->('/delcomment.tt.error.invalidtype2') unless $tp->{'nodetype'} eq 'L'; return $bad_input->('/delcomment.tt.error.alreadydeleted') if $tp->{'state'} eq "D"; # get username of poster $tp->{'userpost'} = LJ::get_username( $tp->{'posterid'} ); # userid of user who posted journal entry my $jposterid = $dbcr->selectrow_array( "SELECT posterid FROM log2 WHERE " . "journalid=? AND jitemid=?", undef, $u->userid, $tp->{itemid} ); my $jposter = LJ::load_userid($jposterid); # can $remote delete this comment? unless ( LJ::Talk::can_delete( $remote, $u, $jposter, $tp->{'userpost'} ) ) { my $err = $u->is_community ? '/delcomment.tt.error.cantdelete.comm' : '/delcomment.tt.error.cantdelete'; return $error->($err); } my $can_manage = $remote->can_manage($u); # can ban if can manage and the comment is by someone else and not anon my $can_ban = $can_manage && $tp->{posterid} && $remote && $remote->userid != $tp->{posterid}; my $can_delthread = $can_manage || $jposterid == $remote->userid; # can mark as spam if they're not the comment poster # or if the account is not sysbanned my $can_spam = $remote && $remote->id != $tp->{'posterid'} && !LJ::sysban_check( 'spamreport', $u->user ); $vars = { can_manage => $can_manage, can_ban => $can_ban, can_spam => $can_spam, can_delthread => $can_delthread, iscomm => $iscomm, u => $u, id => $GET->{'id'}, tp_user => LJ::ljuser( $tp->{'userpost'} ) }; ### perform actions if ( $r->did_post && $POST->{'confirm'} ) { return $error->( LJ::Lang::ml('/talkpost_do.tt.error.invalidform') ) unless LJ::check_form_auth( $POST->{lj_form_auth} ); # mark this as spam? LJ::Talk::mark_comment_as_spam( $u, $tp->{talkid} ) if $POST->{spam} && $can_spam; # delete entire thread? or just the one comment? if ( $POST->{delthread} && $can_delthread ) { # delete entire thread ... LJ::Talk::delete_thread( $u, $tp->{'itemid'}, $tpid ); } else { # delete single comment... LJ::Talk::delete_comment( $u, $tp->{'itemid'}, $tpid, $tp->{'state'} ); } # ban the user, if selected my $msg; if ( $POST->{'ban'} && $can_ban ) { LJ::set_rel( $u->{'userid'}, $tp->{'posterid'}, 'B' ); $u->log_event( 'ban_set', { actiontarget => $tp->{'posterid'}, remote => $remote } ); $msg = LJ::Lang::ml( "/delcomment.tt.success.andban$iscomm", { 'user' => LJ::ljuser( $tp->{'userpost'} ) } ); } $msg ||= LJ::Lang::ml('/delcomment.tt.success.noban'); $msg .= "" . LJ::Lang::ml('/delcomment.tt.success.spam') . "
" if $POST->{spam} && $can_spam; if ($jsmode) { if ( !!$GET->{json} ) { sleep 1 if $LJ::IS_DEV_SERVER; $r->print( to_json( { msg => LJ::strip_html($msg) } ) ); return $r->OK; } else { return "1;"; } } else { $vars->{done} = 1; $vars->{msg} = $msg; } } return DW::Template->render_template( 'delcomment.tt', $vars ); } 1;