# 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. use strict; use LJ::Global::Constants; package LJ; # this function takes an intid and returns the associated keyword/intcount. sub get_interest { my $intid = $_[0]; return undef unless $intid && $intid =~ /^\d+$/; my ( $int, $intcount ); my $memkey = [ $intid, "introw:$intid" ]; my $cached = LJ::MemCache::get($memkey); # memcache row is of form [$intid, $int, $intcount]; if ( $cached && ref $cached eq 'ARRAY' ) { ( $intid, $int, $intcount ) = @$cached; } else { my $dbr = LJ::get_db_reader(); ($int) = $dbr->selectrow_array( "SELECT keyword FROM sitekeywords WHERE kwid=?", undef, $intid ); die $dbr->errstr if $dbr->err; ($intcount) = $dbr->selectrow_array( "SELECT intcount FROM interests WHERE intid=?", undef, $intid ); die $dbr->errstr if $dbr->err; LJ::MemCache::set( $memkey, [ $intid, $int, $intcount ], 3600 * 12 ); } return wantarray() ? ( $int, $intcount ) : $int; } # name: LJ::get_sitekeyword_id # des: Get the id for a global keyword. # args: keyword, autovivify? # des-keyword: A string keyword to get the id of. # returns: Returns a kwid into [dbtable[sitekeywords]]. # If the keyword doesn't exist, it is automatically created for you. # des-autovivify: If present and 1, automatically create keyword. # If present and 0, do not automatically create the keyword. # If not present, default behavior is the old # style -- yes, do automatically create the keyword. # sub get_sitekeyword_id { my ( $kw, $autovivify, %opts ) = @_; $autovivify = 1 unless defined $autovivify; # setup the keyword for use return 0 unless defined $kw && $kw =~ /\S/; $kw = LJ::text_trim( $kw, LJ::BMAX_SITEKEYWORD, LJ::CMAX_SITEKEYWORD ); $kw = LJ::utf8_lc($kw) unless $opts{allowmixedcase}; # get the keyword and insert it if necessary my $dbr = LJ::get_db_reader(); my $kwid = $dbr->selectrow_array( "SELECT kwid FROM sitekeywords WHERE keyword=?", undef, $kw ); $kwid = defined $kwid ? $kwid + 0 : 0; if ( $autovivify && !$kwid ) { # create a new keyword $kwid = LJ::alloc_global_counter('K'); return undef unless $kwid; # attempt to insert the keyword my $dbh = LJ::get_db_writer(); my $rv = $dbh->do( "INSERT IGNORE INTO sitekeywords (kwid, keyword) VALUES (?, ?)", undef, $kwid, $kw ); return undef if $dbh->err; # at this point, if $rv is 0, the keyword is already there so try again unless ($rv) { $kwid = $dbh->selectrow_array( "SELECT kwid FROM sitekeywords WHERE keyword=?", undef, $kw ); $kwid = defined $kwid ? $kwid + 0 : 0; return undef if $dbh->err; } } return $kwid; } sub interest_string_to_list { my $intstr = $_[0]; return unless defined $intstr; $intstr =~ s/^\s+//; # strip leading space $intstr =~ s/\s+$//; # strip trailing space $intstr =~ s/\n/,/g; # newlines become commas $intstr =~ s/\s+/ /g; # strip duplicate spaces from the interest # final list is ,-sep return grep { length } split( /\s*,\s*/, $intstr ); } # This function takes a list of intids and returns the list of uids # for accounts interested in ALL of the given interests. # # Args: arrayref of intids, hashref of opts # Returns: array of uids # # Valid opts: nousers => 1, nocomms => 1 # sub users_with_all_ints { my ( $ints, $opts ) = @_; $opts ||= {}; return unless defined $ints && ref $ints eq 'ARRAY'; my @intids = grep /^\d+$/, @$ints; # numeric only return unless @intids; my @tables; push @tables, 'userinterests' unless $opts->{nousers}; push @tables, 'comminterests' unless $opts->{nocomms}; return unless @tables; # allow restricting to user's circle my $cids; if ( $opts->{circle} && ( my $u = LJ::get_remote() ) ) { my @circle = ( $u->circle_userids, $u->member_of_userids, $u->id ); $cids = join ',', @circle; } my $dbr = LJ::get_db_reader(); my $qs = join ',', map { '?' } @intids; my @uids; foreach (@tables) { my $q = "SELECT userid FROM $_ WHERE intid IN ($qs)"; $q .= " AND userid IN ($cids)" if $cids; my $uref = $dbr->selectall_arrayref( $q, undef, @intids ); die $dbr->errstr if $dbr->err; # Count the number of times the uid appears. # If it's the same as the number of interests, it has all of them. my %ucount; $ucount{ $_->[0] }++ foreach @$uref; push @uids, grep { $ucount{$_} == scalar @intids } keys %ucount; } return @uids; } sub validate_interest_list { my $interrors = ref $_[0] eq "ARRAY" ? shift : []; my @ints = @_; my @valid_ints = (); foreach my $int (@ints) { $int = lc($int); # FIXME: use utf8? $int =~ s/^i like //; # *sigh* next unless $int; # Specific interest failures my ( $bytes, $chars ) = LJ::text_length($int); my $error_string = ''; if ( $int =~ /[\<\>]/ ) { $int = LJ::ehtml($int); $error_string .= '.invalid'; } else { $error_string .= '.bytes' if $bytes > LJ::BMAX_SITEKEYWORD; $error_string .= '.chars' if $chars > LJ::CMAX_SITEKEYWORD; } if ($error_string) { $error_string = "error.interest$error_string"; push @$interrors, [ $error_string, { int => $int, bytes => $bytes, bytes_max => LJ::BMAX_SITEKEYWORD, chars => $chars, chars_max => LJ::CMAX_SITEKEYWORD } ]; next; } push @valid_ints, $int; } return @valid_ints; } # end package LJ functions; begin user object methods. package LJ::User; # $opts is optional, with keys: # forceids => 1 : don't use memcache for loading the intids # forceints => 1 : don't use memcache for loading the interest rows # justids => 1 : return arrayref of intids only, not names/counts # returns otherwise an arrayref of interest rows, sorted by interest name # sub get_interests { my ( $u, $opts ) = @_; $opts ||= {}; return undef unless LJ::isu($u); # first check request cache inside $u if ( my $ints = $u->{_cache_interests} ) { return [ map { $_->[0] } @$ints ] if $opts->{justids}; return $ints; } my $uid = $u->userid; my $uitable = $u->is_community ? 'comminterests' : 'userinterests'; # load the ids my $mk_ids = [ $uid, "intids:$uid" ]; my $ids; $ids = LJ::MemCache::get($mk_ids) unless $opts->{forceids}; unless ( $ids && ref $ids eq "ARRAY" ) { $ids = []; my $dbh = LJ::get_db_writer(); my $sth = $dbh->prepare("SELECT intid FROM $uitable WHERE userid=?"); $sth->execute($uid); push @$ids, $_ while ($_) = $sth->fetchrow_array; LJ::MemCache::add( $mk_ids, $ids, 3600 * 12 ); } # FIXME: set a 'justids' $u cache key in this case, then only return that # later if 'justids' is requested? probably not worth it. return $ids if $opts->{justids}; # load interest rows my %need; $need{$_} = 1 foreach @$ids; my @ret; unless ( $opts->{forceints} ) { if ( my $mc = LJ::MemCache::get_multi( map { [ $_, "introw:$_" ] } @$ids ) ) { while ( my ( $k, $v ) = each %$mc ) { next unless $k =~ /^introw:(\d+)/; delete $need{$1}; push @ret, $v; } } } if (%need) { my $ids = join( ",", map { $_ + 0 } keys %need ); my $dbr = LJ::get_db_reader(); my $ints = $dbr->selectall_hashref( "SELECT kwid, keyword FROM sitekeywords " . "WHERE kwid IN ($ids)", 'kwid' ); my $counts = $dbr->selectall_hashref( "SELECT intid, intcount FROM interests " . "WHERE intid IN ($ids)", 'intid' ); my $memc_store = 0; foreach my $intid ( keys %$ints ) { my $int = $ints->{$intid}->{keyword}; my $count = $counts->{$intid}->{intcount} + 0; my $aref = [ $intid, $int, $count ]; # minimize latency... only store 25 into memcache at a time # (too bad we don't have set_multi.... hmmmm) if ( $memc_store++ < 25 ) { # if the count is fairly high, keep item in memcache longer, # since count's not so important. my $expire = $count < 10 ? 3600 * 12 : 3600 * 48; LJ::MemCache::add( [ $intid, "introw:$intid" ], $aref, $expire ); } push @ret, $aref; } } @ret = sort { $a->[1] cmp $b->[1] } @ret; return $u->{_cache_interests} = \@ret; } sub interest_count { my $u = shift; return undef unless LJ::isu($u); # FIXME: fall back to SELECT COUNT(*) if not cached already? return scalar @{ $u->get_interests( { justids => 1 } ) }; } sub interest_list { my $u = shift; return undef unless LJ::isu($u); return map { $_->[1] } @{ $u->get_interests() }; } sub interest_update { my ( $u, %opts ) = @_; return undef unless LJ::isu($u); my ( $add, $del ) = ( $opts{add}, $opts{del} ); return 1 unless $add || $del; # nothing to do my $lock; unless ( $opts{has_lock} ) { while (1) { $lock = LJ::locker()->trylock( 'interests:' . $u->userid ); last if $lock; # pause for 0.0-0.3 seconds to shuffle things up. generally good behavior # when you're contending for locks. select undef, undef, undef, rand() * 0.3; } } my %wanted_add = map { $_ => 1 } @$add; my %wanted_del = map { $_ => 1 } @$del; my %cur_ids = map { $_ => 1 } @{ $u->get_interests( { justids => 1, forceids => 1 } ) }; # track if we made changes to refresh memcache later. my $did_mod = 0; # community interests go in a different table than user interests, # though the schemas are the same so we can run the same queries on them my $uitable = $u->is_community ? 'comminterests' : 'userinterests'; my $uid = $u->userid; my $dbh = LJ::get_db_writer() or die LJ::Lang::ml("error.nodb"); my @filtered_del = grep { delete $cur_ids{$_} && !$wanted_add{$_} } @$del; if ( $del && @filtered_del ) { $did_mod = 1; my $intid_in = join ',', map { $dbh->quote($_) } @filtered_del; $dbh->do("DELETE FROM $uitable WHERE userid=$uid AND intid IN ($intid_in)"); die $dbh->errstr if $dbh->err; $dbh->do("UPDATE interests SET intcount=intcount-1 WHERE intid IN ($intid_in)"); die $dbh->errstr if $dbh->err; } my @filtered_add = grep { !$cur_ids{$_}++ && !$wanted_del{$_} } @$add; if ( $add && @filtered_add ) { # assume we've already checked maxinterests $did_mod = 1; my $intid_in = join ',', map { $dbh->quote($_) } @filtered_add; my $sqlp = join ',', map { "(?,?)" } @filtered_add; $dbh->do( "REPLACE INTO $uitable (userid, intid) VALUES $sqlp", undef, map { ( $uid, $_ ) } @filtered_add ); die $dbh->errstr if $dbh->err; # set a zero intcount for any new ints $dbh->do( "INSERT IGNORE INTO interests (intid, intcount) VALUES $sqlp", undef, map { ( $_, 0 ) } @filtered_add ); die $dbh->errstr if $dbh->err; # now do the increment for all ints $dbh->do("UPDATE interests SET intcount=intcount+1 WHERE intid IN ($intid_in)"); die $dbh->errstr if $dbh->err; } # do migrations to clean up userinterests vs comminterests conflicts # also clears memcache and object cache for intids if needed $u->lazy_interests_cleanup($did_mod); return 1; } # return hashref with intname => intid sub interests { my ( $u, $opts ) = @_; return undef unless LJ::isu($u); delete $opts->{justids} if $opts && ref $opts; my $uints = $u->get_interests($opts); my %interests; foreach my $int (@$uints) { $interests{ $int->[1] } = $int->[0]; # $interests{name} = intid } return \%interests; } sub lazy_interests_cleanup { my ( $u, $expire ) = @_; my $dbh = LJ::get_db_writer(); if ( $u->is_community ) { $dbh->do( "INSERT IGNORE INTO comminterests SELECT * FROM userinterests WHERE userid=?", undef, $u->id ); $dbh->do( "DELETE FROM userinterests WHERE userid=?", undef, $u->id ); } else { $dbh->do( "INSERT IGNORE INTO userinterests SELECT * FROM comminterests WHERE userid=?", undef, $u->id ); $dbh->do( "DELETE FROM comminterests WHERE userid=?", undef, $u->id ); } # don't expire memcache unless requested return 1 unless $expire; LJ::memcache_kill( $u, "intids" ); $u->{_cache_interests} = undef; return 1; } # des: Change a user's interests. # des-new: listref of new interests # returns: 1 on success, undef on failure sub set_interests { my ( $u, $new ) = @_; $u = LJ::want_user($u) or return undef; return undef unless ref $new eq 'ARRAY'; my $lock; while (1) { $lock = LJ::locker()->trylock( 'interests:' . $u->userid ); last if $lock; # pause for 0.0-0.3 seconds to shuffle things up. generally good behavior # when you're contending for locks. select undef, undef, undef, rand() * 0.3; } my $old = $u->interests( { forceids => 1 } ); my %int_add = (); my %int_del = %$old; # assume deleting everything, unless in @$new my @valid_ints = LJ::validate_interest_list(@$new); foreach my $int (@valid_ints) { $int_add{$int} = 1 unless $old->{$int}; delete $int_del{$int}; } ### do we have new interests to add? my @new_intids = (); ## existing IDs we'll add for this user foreach my $int ( keys %int_add ) { my $intid = LJ::get_sitekeyword_id($int); push @new_intids, $intid if $intid; } # Note this does NOT check against maxinterests, do that in the caller. $u->interest_update( add => \@new_intids, del => [ values %int_del ], has_lock => 1 ); LJ::Hooks::run_hooks( "set_interests", $u, \%int_del, \@new_intids ); # interest => intid return 1; } # arguments: hashref of submitted form and list of user's previous intids # returns: hashref with number of ints added (or toomany) and deleted sub sync_interests { my ( $u, $args, @intids ) = @_; warn "sync_interests: invalid arguments" and return undef unless LJ::isu($u) and ref $args eq "HASH"; @intids = grep /^\d+$/, @intids; # numeric my %uint = reverse %{ $u->interests }; # intid => interest my $rv = {}; my ( @todel, @toadd ); foreach my $intid (@intids) { next unless $intid > 0; # prevent adding zero or negative intid push @todel, $intid if $uint{$intid} && !$args->{"int_$intid"}; push @toadd, $intid if !$uint{$intid} && $args->{"int_$intid"}; } my $addcount = scalar(@toadd); my $delcount = scalar(@todel); if ($addcount) { my $intcount = scalar( keys %uint ) + $addcount - $delcount; my $maxinterests = $u->count_max_interests; if ( $intcount > $maxinterests ) { # let the user know they're over the limit $rv->{toomany} = $maxinterests; @toadd = (); # deletion still OK } } $u->interest_update( add => \@toadd, del => \@todel ); $rv->{added} = $addcount; $rv->{deleted} = $delcount; return $rv; } 1;