#!/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;
