mourningdove/cgi-bin/LJ/UniqCookie.pm

529 lines
14 KiB
Perl
Raw Permalink Normal View History

2026-05-24 01:03:05 +00:00
#!/usr/bin/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::UniqCookie;
use strict;
use Carp qw(croak);
use LJ::Utils qw(rand_chars);
my %req_cache_uid2uniqs = (); # uid => [ uniq1, uniq2, ... ]
my %req_cache_uniq2uids = (); # uniq => [ uid1, uid2, ... ]
# number of uniq cookies to keep in cache + db before being cleaned
my $window_size = 10;
sub clear_request_cache {
my $class = shift;
%req_cache_uid2uniqs = ();
%req_cache_uniq2uids = ();
}
sub set_request_cache_by_user {
my $class = shift;
my ( $u_arg, $uniq_list ) = @_;
my $uid = LJ::want_userid($u_arg)
or croak "invalid user arg: $u_arg";
croak "invalid uniq list: $uniq_list"
unless ref $uniq_list eq 'ARRAY';
return $req_cache_uid2uniqs{$uid} = $uniq_list;
}
sub get_request_cache_by_user {
my $class = shift;
my $u_arg = shift;
my $uid = LJ::want_userid($u_arg)
or croak "invalid user arg: $u_arg";
return $req_cache_uid2uniqs{$uid};
}
sub set_request_cache_by_uniq {
my $class = shift;
my ( $uniq, $user_list ) = @_;
croak "invalid uniq arg: $uniq"
unless length $uniq;
croak "invalid user list: $user_list"
unless ref $user_list eq 'ARRAY';
my @userids = ();
foreach my $u_arg (@$user_list) {
my $uid = LJ::want_userid($u_arg)
or croak "invalid arg in user_list: $u_arg";
push @userids, $uid;
}
$req_cache_uniq2uids{$uniq} = \@userids;
}
sub get_request_cache_by_uniq {
my $class = shift;
my $uniq = shift;
croak "invalid 'uniq' arg: $uniq"
unless length $uniq;
return $req_cache_uniq2uids{$uniq};
}
sub delete_memcache_by_user {
my $class = shift;
my $u_arg = shift;
my $uid = LJ::want_userid($u_arg)
or croak "invalid user arg: $u_arg";
LJ::MemCache::delete("uid2uniqs:$uid");
}
sub delete_memcache_by_uniq {
my $class = shift;
my $uniq = shift;
croak "invalid 'uniq' arg: $uniq"
unless length $uniq;
LJ::MemCache::delete("uniq2uids:$uniq");
}
sub set_memcache_by_user {
my $class = shift;
my ( $u_arg, $uniq_list ) = @_;
my $uid = LJ::want_userid($u_arg)
or croak "invalid user arg: $u_arg";
# we store uid => [] and uniq => [], so defined but false
# is okay as a value of these memcache keys, but not as part of the key
my $exptime = 3600;
LJ::MemCache::set( "uid2uniqs:$uid" => $uniq_list, $exptime );
}
sub get_memcache_by_user {
my $class = shift;
my $u_arg = shift;
my $uid = LJ::want_userid($u_arg)
or die "invalid user arg: $u_arg";
return LJ::MemCache::get("uid2uniqs:$uid");
}
sub set_memcache_by_uniq {
my $class = shift;
my ( $uniq, $user_list ) = @_;
croak "invalid 'uniq' argument: $uniq"
unless length $uniq;
croak "invalid user list: $user_list"
unless ref $user_list eq 'ARRAY';
my @userids = ();
foreach my $u_arg (@$user_list) {
my $uid = LJ::want_userid($u_arg)
or croak "invalid arg in user_list: $u_arg";
push @userids, $uid;
}
# we store uid => [] and uniq => [], so defined but false
# is okay as a value of these memcache keys, but not as part of the key
my $exptime = 3600;
LJ::MemCache::set( "uniq2uids:$uniq" => \@userids, $exptime );
}
sub get_memcache_by_uniq {
my $class = shift;
my $uniq = shift;
croak "invalid 'uniq' argument: $uniq"
unless length $uniq;
return LJ::MemCache::get("uniq2uids:$uniq");
}
sub save_mapping {
my $class = shift;
return if $class->is_disabled;
my ( $uniq, $uid_arg ) = @_; # no extra parts, only ident
croak "invalid uniq: $uniq"
unless length $uniq;
my $uid = LJ::want_userid($uid_arg);
croak "invalid userid arg: $uid_arg"
unless $uid;
my $dbh = LJ::get_db_writer()
or die "unable to contact global master for uniq mapping";
# allow tests to specify an insertion time callback which specifies
# how we calculate insertion times for rows
my $time_sql = "UNIX_TIMESTAMP()";
if ($LJ::_T_UNIQCOOKIE_MODTIME_CB) {
$time_sql = int( $LJ::_T_UNIQCOOKIE_MODTIME_CB->( $uniq, $uid ) );
}
my $rv = $dbh->do( "REPLACE INTO uniqmap SET uniq=?, userid=?, modtime=$time_sql",
undef, $uniq, $uid );
die $dbh->errstr if $dbh->err;
# clear memcache so its next query will reflect our changes
$class->delete_memcache_by_uniq($uniq);
$class->delete_memcache_by_user($uid);
# also clear request cache
$class->clear_request_cache;
# we clean on cache misses in ->load_mapping, but we also want
# to randomly clean on write actions so that we don't end up
# with users who write many rows but for some reason never
# load any rows, and are therefore never cleaned
if ( $class->should_lazy_clean ) {
LJ::DB::no_cache(
sub {
$class->load_mapping( user => $uid );
# no need for uniq => $uniq case
}
);
}
return $rv;
}
sub should_lazy_clean {
my $class = shift;
# one in 100 times
my $pct = 0.01;
if ($LJ::_T_UNIQCOOKIE_LAZY_CLEAN_PCT) {
$pct = $LJ::_T_UNIQCOOKIE_LAZY_CLEAN_PCT;
}
return rand() <= $pct;
}
sub is_disabled {
my $class = shift;
my $remote = LJ::get_remote();
my $uniq = $class->current_uniq;
return !LJ::is_enabled( 'uniq_mapping', $remote, $uniq );
}
sub guess_remote {
my $class = shift;
my $uniq = $class->current_uniq;
return unless $uniq;
my $uid = $class->load_mapping( uniq => $uniq );
return LJ::load_userid($uid);
}
# if 'uniq' passed in, returns mapped userid
# if 'remote' passed in, returns mapped uniq
sub load_mapping {
my $class = shift;
return if $class->is_disabled;
my %opts = @_;
my $uniq = delete $opts{uniq};
my $user = delete $opts{user};
my $ret = sub {
return wantarray() ? @_ : $_[0];
};
if ($user) {
my $uid = LJ::want_userid($user)
or croak "invalid user arg: $user";
return $ret->( $class->_load_mapping_uid( $uid, %opts ) );
}
if ($uniq) {
return $ret->( $class->_load_mapping_uniq( $uniq, %opts ) );
}
croak "must load mapping via 'uniq' or 'user'";
}
sub _load_mapping_uid {
my $class = shift;
my $uid = shift;
# first, check request cache
my $cache_val = $class->get_request_cache_by_user($uid);
return @$cache_val if defined $cache_val;
# second, check memcache
my $memval = $class->get_memcache_by_user($uid);
if ($memval) {
$class->set_request_cache_by_user( $uid => $memval );
return @$memval;
}
my $dbh = LJ::get_db_writer()
or die "unable to contact global reader";
my $limit = $window_size + 1;
my $sth = $dbh->prepare( "SELECT uniq, modtime FROM uniqmap WHERE userid=? "
. "ORDER BY modtime DESC LIMIT $limit" );
$sth->execute($uid);
die $dbh->errstr if $dbh->err;
my ( @uniq_list, $min_modtime );
while ( my ( $curr_uniq, $modtime ) = $sth->fetchrow_array ) {
push @uniq_list, $curr_uniq;
$min_modtime = $modtime if !$min_modtime || $modtime < $min_modtime;
}
# we got out more rows than we allow after cleaning, so an insert
# has happened ... we'll clean that now
my $delete_ct = 0;
if ( @uniq_list >= $limit ) {
$delete_ct = $dbh->do( "DELETE FROM uniqmap WHERE userid=? AND modtime<=?",
undef, $uid, $min_modtime );
@uniq_list = @uniq_list[ 0 .. $window_size - 1 ];
}
# allow tests to register a callback to determine
# how many rows were deleted
if ( ref $LJ::_T_UNIQCOOKIE_DELETE_CB ) {
$LJ::_T_UNIQCOOKIE_DELETE_CB->( 'userid', $delete_ct );
}
# now set the value we retrieved in both memcache values
$class->set_request_cache_by_user( $uid => \@uniq_list );
$class->set_memcache_by_user( $uid => \@uniq_list );
return @uniq_list;
}
sub _load_mapping_uniq {
my $class = shift;
my $uniq = shift;
# first, check request cache
my $cache_val = $class->get_request_cache_by_uniq($uniq);
return @$cache_val if defined $cache_val;
# second, check memcache
my $memval = $class->get_memcache_by_uniq($uniq);
if ($memval) {
$class->set_request_cache_by_uniq( $uniq => $memval );
return @$memval;
}
my $dbh = LJ::get_db_reader()
or die "unable to contact global reader";
my $limit = $window_size + 1;
my $sth = $dbh->prepare( "SELECT userid, modtime FROM uniqmap WHERE uniq=? "
. "ORDER BY modtime DESC LIMIT $limit" );
$sth->execute($uniq);
die $dbh->errstr if $dbh->err;
my ( @uid_list, $min_modtime );
while ( my ( $curr_uid, $modtime ) = $sth->fetchrow_array ) {
push @uid_list, $curr_uid;
$min_modtime = $modtime if !$min_modtime || $modtime < $min_modtime;
}
# we got out more rows than we allow after cleaning, so an insert
# has happened ... we'll clean that now
my $delete_ct = 0;
if ( @uid_list >= $limit ) {
$delete_ct = $dbh->do( "DELETE FROM uniqmap WHERE uniq=? AND modtime<=?",
undef, $uniq, $min_modtime );
# trim the cached/returned value as well
@uid_list = @uid_list[ 0 .. $window_size - 1 ];
}
# allow tests to register a callback to determine
# how many rows were deleted
if ( ref $LJ::_T_UNIQCOOKIE_DELETE_CB ) {
$LJ::_T_UNIQCOOKIE_DELETE_CB->( 'uniq', $delete_ct );
}
# now set the value we retrieved in both memcache values
$class->set_request_cache_by_uniq( $uniq => \@uid_list );
$class->set_memcache_by_uniq( $uniq => \@uid_list );
return @uid_list;
}
sub generate_uniq_ident {
my $class = shift;
return LJ::rand_chars(15);
}
###############################################################################
# These methods require web context, they deal with BML::get_request() and cookies
#
sub ensure_cookie_value {
my $class = shift;
return unless LJ::is_web_context();
my $r = DW::Request->get;
return unless $r;
my ( $uniq, $uniq_time, $uniq_extra ) = $class->parts_from_cookie;
# set this uniq as our current
# -- will be overridden later if we generate a new value
$class->set_current_uniq($uniq) if $uniq;
# if no cookie, create one. if older than a day, revalidate
my $now = time();
return if $uniq && $now - $uniq_time < 86400;
my $setting_new = 0;
unless ($uniq) {
$setting_new = 1;
$uniq = $class->generate_uniq_ident;
}
my $new_cookie_value = "$uniq:$now";
my $hook_saved_mapping = 0;
if ( LJ::Hooks::are_hooks('transform_ljuniq_value') ) {
$new_cookie_value = LJ::Hooks::run_hook(
'transform_ljuniq_value',
{
value => $new_cookie_value,
extra => $uniq_extra,
hook_saved_mapping => \$hook_saved_mapping
}
);
# if it changed the actual uniq identifier (first part)
# then we'll need to
$uniq = $class->parts_from_value($new_cookie_value);
}
# set this new or transformed uniq in Apache request notes
$class->set_current_uniq($uniq);
if ( $setting_new && !$hook_saved_mapping && !$class->is_disabled ) {
my $remote = LJ::get_remote();
$class->save_mapping( $uniq => $remote ) if $remote;
}
# set uniq cookies for all cookie_domains
my @domains = ref $LJ::COOKIE_DOMAIN ? @$LJ::COOKIE_DOMAIN : ($LJ::COOKIE_DOMAIN);
foreach my $dom (@domains) {
$r->add_cookie(
name => 'ljuniq',
value => $new_cookie_value,
expires => '+60d',
domain => $dom || undef,
path => '/'
);
}
return;
}
sub sysban_should_block {
my $class = shift;
return 0 unless LJ::is_web_context();
my $apache_r = BML::get_request();
my $uri = $apache_r->uri;
return 0 if $LJ::BLOCKED_BOT_URI && index( $uri, $LJ::BLOCKED_BOT_URI ) == 0;
# if cookie exists, check for sysban
if ( my @cookieparts = $class->parts_from_cookie ) {
my ( $uniq, $uniq_time, $uniq_extra ) = @cookieparts;
return 1 if LJ::sysban_check( 'uniq', $uniq );
}
return 0;
}
# returns: (uniq_val, uniq_time, uniq_extra)
sub parts_from_cookie {
my $class = shift;
return unless LJ::is_web_context();
my $r = DW::Request->get;
return $class->parts_from_value( $r->cookie('ljuniq') );
}
# returns: (uniq_val, uniq_time, uniq_extra)
sub parts_from_value {
my ( $class, $value ) = @_;
if ( $value && $value =~ /^([a-zA-Z0-9]{15}):(\d+)(.+)$/ ) {
return wantarray() ? ( $1, $2, $3 ) : $1;
}
return;
}
sub set_current_uniq {
my ( $class, $uniq ) = @_;
$LJ::REQ_CACHE{current_uniq} = $uniq;
return unless LJ::is_web_context();
my $r = DW::Request->get;
$r->note( uniq => $uniq );
return;
}
sub current_uniq {
my $class = shift;
if ($LJ::_T_UNIQCOOKIE_CURRENT_UNIQ) {
return $LJ::_T_UNIQCOOKIE_CURRENT_UNIQ;
}
# should be in $LJ::REQ_CACHE, so return from
# there if it is
my $val = $LJ::REQ_CACHE{current_uniq};
return $val if $val;
# otherwise, legacy place is in $r->notes
return unless LJ::is_web_context();
my $apache_r = BML::get_request();
# see if a uniq is set for this request
# -- this accounts for cases when the cookie was initially
# set in this request, so it wasn't received in an
# incoming headerno cookie was sent in
return $apache_r->notes->{uniq};
}
1;