
package Apache::ASP;

# quickly decomped out of Apache::ASP so we could load the routines only
# when we are managing State objects

use Apache::ASP::State;

use strict;
use vars qw(
  $CleanupGroups
 $SessionIDLength $SessionTimeout $StateManager
  $DefaultStateDB $DefaultStateSerializer
);

$SessionTimeout = 1200;
$StateManager   = 10;

# Some OS's have hashed directory lookups up to 16 bytes, so we leave room
# for .lock extension ... nevermind, security is more important, back to 32
# $SessionIDLength = 11;
$SessionIDLength = 32;
$DefaultStateDB = 'SDBM_File';
$DefaultStateSerializer = 'Data::Dumper';

sub InitState {
    my $self = shift;
    my $r = $self->{r};
    my $global_asa = $self->{GlobalASA};

    ## STATE INITS
    # what percent of the session_timeout's time do we garbage collect
    # state files and run programs like Session_OnEnd and Application_OnEnd
    $self->{state_manager} = $r->dir_config('StateManager') 
      || $Apache::ASP::StateManager;    

    # state is the path where state files are stored, like $Session, $Application, etc.
    $self->{state_dir}   = $r->dir_config('StateDir') || $self->{global}.'/.state';
    $self->{state_dir}   =~ tr///; # untaint
    $self->{no_session}  = (defined $r->dir_config('AllowSessionState')) ? (! $r->dir_config('AllowSessionState')) : 0;
    $self->{state_serialize} = $r->dir_config('ApplicationSerialize');

    if($self->{state_db}    = $r->dir_config('StateDB')) {
	# StateDB - Check StateDB module support 
	$Apache::ASP::State::DB{$self->{state_db}} ||
	  $self->Error("$self->{state_db} is not supported for StateDB, try: " . 
		       join(", ", keys %Apache::ASP::State::DB));
	$self->{state_db} =~ /^(.*)$/; # untaint
	$self->{state_db} = $1; # untaint
	# load the state database module && serializer
	$self->LoadModule('StateDB', $self->{state_db});
    }
    if($self->{state_serializer} = $r->dir_config('StateSerializer')) {
	$self->{state_serializer} =~ tr///; # untaint
	$self->LoadModule('StateSerializer', $self->{state_serializer});
    }

    # INTERNAL tie to the application internal info
    my %Internal;
    tie(%Internal, 'Apache::ASP::State', $self, 'internal', 'server')
      || $self->Error("can't tie to internal state");
    my $internal = $self->{Internal} = bless \%Internal, 'Apache::ASP::State';
    $self->{state_serialize} && $internal->LOCK;

    # APPLICATION create application object
    $self->{app_state} = (defined $r->dir_config('AllowApplicationState') ? 
			   $r->dir_config('AllowApplicationState') : 1);
    if($self->{app_state}) {	

	# load at runtime for CGI environments, preloaded for mod_perl
	require Apache::ASP::Application;

	($self->{Application} = &Apache::ASP::Application::new($self)) 
	  || $self->Error("can't get application state");
	$self->{state_serialize} && $self->{Application}->Lock;

    } else {
	$self->{dbg} && $self->Debug("no application allowed config");
    }

    # SESSION if we are tracking state, set up the appropriate objects
    my $session;
    if(! $self->{no_session}) {
	## SESSION INITS
	$self->{cookie_path}       = $r->dir_config('CookiePath') || '/';
	$self->{paranoid_session}  = $r->dir_config('ParanoidSession') || 0;
	$self->{remote_ip}         = $r->connection()->remote_ip();
	$self->{session_count}     = $r->dir_config('SessionCount');
	
	# cookieless session support, cascading values
	$self->{session_url_parse_match} = $r->dir_config('SessionQueryParseMatch');
	$self->{session_url_parse} = $self->{session_url_parse_match} || $r->dir_config('SessionQueryParse');
	$self->{session_url_match} = $self->{session_url_parse_match} || $r->dir_config('SessionQueryMatch');
	$self->{session_url} = $self->{session_url_parse} || $self->{session_url_match} || $r->dir_config('SessionQuery');
	$self->{session_url_force} = $r->dir_config('SessionQueryForce');
	
	$self->{session_serialize} = $r->dir_config('SessionSerialize');
	$self->{secure_session}    = $r->dir_config('SecureSession');
	# session timeout in seconds since that is what we work with internally
	$self->{session_timeout}   = $r->dir_config('SessionTimeout') ? 
	  $r->dir_config('SessionTimeout') * 60 : $Apache::ASP::SessionTimeout;
	$self->{'ua'}              = $ENV{HTTP_USER_AGENT} || 'UNKNOWN UA';
	# refresh group by some increment smaller than session timeout
	# to withstand DoS, bruteforce guessing attacks
	# defaults to checking the group once every 2 minutes
	$self->{group_refresh}     = int($self->{session_timeout} / $self->{state_manager});
	
	# Session state is dependent on internal state

	# load at runtime for CGI environments, preloaded for mod_perl
	require Apache::ASP::Session;

	$session = $self->{Session} = &Apache::ASP::Session::new($self)
	  || $self->Die("can't create session");
	$self->{state_serialize} && $session->Lock();
	
    } else {
	$self->{dbg} && $self->Debug("no sessions allowed config");
    }

    # update after long state init, possible with SessionSerialize config
    $self->{Response}->IsClientConnected();

    # POSTPOSE STATE EVENTS, so we can delay the Response object creation
    # until after the state objects are created
    if($session) {
	my $last_session_timeout;
	if($session->Started()) {
	    # we only want one process purging at a time
	    if($self->{app_state}) {
		$internal->LOCK();
		if(($last_session_timeout = $internal->{LastSessionTimeout} || 0) < time()) {
		    $internal->{'LastSessionTimeout'} = $self->{session_timeout} + time;
		    $internal->UNLOCK();
		    $self->{Application}->Lock;
		    my $obj = tied(%{$self->{Application}});
		    if($self->CleanupGroups('PURGE')) {
			$last_session_timeout && $global_asa->ApplicationOnEnd();
			$global_asa->ApplicationOnStart();
		    }
		    $self->{Application}->UnLock;
		} 
		$internal->UNLOCK();
	    }
	    $global_asa->SessionOnStart();
	}

	if($self->{app_state}) {
	    # The last session timeout should only be updated every group_refresh period
	    # another optimization, rand() so not all at once either
	    $internal->LOCK();
	    $last_session_timeout ||= $internal->{'LastSessionTimeout'};
	    if($last_session_timeout < $self->{session_timeout} + time + 
	       (rand() * $self->{group_refresh} / 2)) 
	      {
		  $self->{dbg} && $self->Debug("updating LastSessionTimeout from $last_session_timeout");
		  $internal->{'LastSessionTimeout'} = 
		    $self->{session_timeout} + time() + $self->{group_refresh};
	      }
	    $internal->UNLOCK();
	}
    }

    $self;
}

# Cleanup a state group, by default the group of the current session
# We do this currently in DESTROY, which happens after the current
# script has been executed, so that cleanup doesn't happen until
# after output to user
#
# We always exit unless there is a $Session defined, since we only 
# cleanup groups of sessions if sessions are allowed for this script
sub CleanupGroup {
    my($self, $group_id, $force) = @_;
    return unless $self->{Session};

    my $asp = $self; # bad hack for some moved around code
    $force ||= 0;

    # GET GROUP_ID
    my $state;
    unless($group_id) {
	$state = $self->{Session}{_STATE};
	$group_id = $state->GroupId();
    }

    # we must have a group id to work with
    $asp->Error("no group id") unless $group_id;
    my $group_key = "GroupId" . $group_id;

    # cleanup timed out sessions, from current group
    my $internal = $asp->{Internal};
    $internal->LOCK();
    my $group_check = $internal->{$group_key} || 0;
    unless($force || ($group_check < time())) {
	$internal->UNLOCK();
	return;
    }
    
    # set the next group_check, randomize a bit to unclump the group checks,
    # for 20 minute session timeout, had rand() / 2 + .5, but it was still
    # too clumpy, going with pure rand() now, even if a bit less efficient

    my $next_check = int($asp->{group_refresh} * rand()) + 1;
    $internal->{$group_key} = time() + $next_check;
    $internal->UNLOCK();

    ## GET STATE for group
    $state ||= &Apache::ASP::State::new($asp, $group_id);
    my $ids = $state->GroupMembers() || [];

    # don't return so we can't delete the empty group later
#    return unless scalar(@$ids);

    $asp->{dbg} && $asp->Debug("group check $group_id, next in $next_check sec");
    my $id = $self->{Session}->SessionID();
    my $deleted = 0;
    $internal->LOCK();
    $asp->{dbg} && $asp->Debug("checking group ids", $ids);
    for my $id (@$ids) {
#	if($id eq $_) {
#	    $asp->{dbg} && $asp->Debug("skipping delete self", {id => $id});
#	    next;
#	}
	
	# we lock the internal, so a session isn't being initialized
	# while we are garbage collecting it... we release it every
	# time so we don't starve session creation if this is a large
	# directory that we are garbage collecting
	my $idata = $internal->{$id};
	my $timeout = $idata->{timeout} || 0;
	
	unless($timeout) {
	    # we don't have the timeout always, since this session
	    # may just have been created, just in case this is 
	    # a corrupted session (does this happen still ??), we give it
	    # a timeout now, so we will be sure to clean it up 
	    # eventualy
	    $idata->{timeout} = time() + $asp->{session_timeout};
	    $internal->{$id} = $idata;
	    $asp->Debug("resetting timeout for $id to $idata->{timeout}");
	    next;
	}	
	# only delete sessions that have timed out
	unless($timeout < time()) {
	    $asp->{dbg} && $asp->Debug("not timed out with $timeout");
	    next;
	}
	
	# UPDATE & UNLOCK, as soon as we update internal, we may free it
	# definately don't lock around SessionOnEnd, as it might take
	# a while to process	
	
	# set the timeout for this session forward so it won't
	# get garbage collected by another process
	$asp->{dbg} && $asp->Debug("resetting timeout for deletion lock on $id");
	$internal->{$id} = {
			   %{$internal->{$id}},
			   'timeout' => time() + $asp->{session_timeout},
			   'end' => 1,
			  };
	
	
	# unlock many times in case we are locked above this loop
	for (1..3) { $internal->UNLOCK() }
	$asp->{GlobalASA}->SessionOnEnd($id);
	$internal->LOCK;
	
	# set up state
	my($member_state) = Apache::ASP::State::new($asp, $id);	
	if(my $count = $member_state->Delete()) {
	    $asp->{dbg} && 
	      $asp->Debug("deleting session", {
					       session_id => $id, 
					       files_deleted => $count,
					      });
	    $deleted++;
	    delete $internal->{$id};
	} else {
	    $asp->Error("can't delete session id: $id");
	    next;
	}
    }
    $internal->UNLOCK();

    #### LEAVE DIRECTORIES, NASTY RACE CONDITION POTENTIAL
    ## NOW PRUNE ONLY DIRECTORIES THAT WE DON'T NEED TO KEEP
    ## FOR PERFORMANCE
    # REMOVE DIRECTORY, LOCK 
    # if the directory is still empty, remove it, lock it 
    # down so no new sessions will be created in it while we 
    # are testing
    if($deleted == @$ids) {
	if ($state->GroupId !~ /^[0]/) {
	    $asp->{Internal}->LOCK();
	    my $ids = $state->GroupMembers();
	    if(@{$ids} == 0) {
		$self->Log("purging stale group ".$state->GroupId.", which should only happen ".
			   "after Apache::ASP upgrade to beyond 2.09");
		$state->DeleteGroupId();
	    }
	    $asp->{Internal}->UNLOCK();
	}
    }

    $deleted;
}

sub CleanupGroups {
    my($self, $force) = @_;
    return unless $self->{Session};

    my $cleanup = 0;
    my $state_dir = $self->{state_dir};
    my $internal = $self->{Internal};
    $force ||= 0;

    $self->Debug("forcing groups cleanup") if ($self->{dbg} && $force);

    # each apache process has an internal time in which it 
    # did its last check, once we have passed that, we check
    # $Internal for the last time the check was done.  We
    # break it up in this way so that locking on $Internal
    # does not become another bottleneck for scripts
    if($force || ($Apache::ASP::CleanupGroups{$state_dir} || 0) < time()) {
	# /8 to keep it less bursty... since we check groups every group_refresh/2
	# we'll average 1/4 of the groups everytime we check them on a busy server
	$Apache::ASP::CleanupGroups{$state_dir} = time() + $self->{group_refresh}/8;
	$self->{dbg} && $self->Debug("testing internal time for cleanup groups");
	if($self->CleanupMaster) {
	    $internal->LOCK();
	    if($force || ($internal->{CleanupGroups} < (time - $self->{group_refresh}/8))) {
		$internal->{CleanupGroups} = time;
		$cleanup = 1;
	    }
	    $internal->UNLOCK;
	}
    }
    return unless $cleanup;

    # clean cache, so caching won't affect CleanupGroups() being called multiple times
    $self->{internal_cached_keys} = undef;

    # only one process doing CleanupGroup at a time now, so OK
    # lock around, necessary when keeping empty group directories
    my $groups = $self->{Session}{_SELF}{'state'}->DefaultGroups();
    $self->{dbg} && $self->Debug("groups ", $groups);
    my($sum_active, $sum_deleted);
    $internal->LOCK();
    my $start_cleanup = time;
    for(@{$groups}) {
	$sum_deleted = $self->CleanupGroup($_, $force);
	if ($start_cleanup > time) {
	    # every second, take a breather in the lock management
	    # so that sessions can be created, and the like, so for 
	    # long purges, the application will get sticky in 1 second
	    # bursts
	    $start_cleanup = time;
	    $internal->UNLOCK;
	    $internal->LOCK;
	    last unless $self->CleanupMaster;
	}
    }
    $internal->UNLOCK();
    $self->{dbg} && $self->Debug("cleanup groups", { deleted => $sum_deleted }) if $self->{dbg};

    # boolean true at least for master
    $sum_deleted || 1; 
}

sub CleanupMaster {
    my $self = shift;
    my $internal = $self->{Internal};
    
    $internal->LOCK;
    my $master = $internal->{CleanupMaster} || 
      {
       ServerID => '',
       PID => 0,
       Checked => 0,       
      };

    my $is_master = (($master->{ServerID} eq $ServerID) and ($master->{PID} eq $$)) ? 1 : 0;
    $self->{dbg} && $self->Debug(current_master => $master, is_master => $is_master );
    my $stale_time = $is_master ? $self->{group_refresh} / 4 : 
      $self->{group_refresh} / 2 + int($self->{group_refresh} * rand() / 2) + 1;
    $stale_time += $master->{Checked};
    
    if($stale_time < time()) {
	$internal->{CleanupMaster} =
	  {
	   ServerID => $ServerID,
	   PID => $$,
	   Checked => time()
	  };
	$internal->UNLOCK; # flush write
	$self->{dbg} && $self->Debug("$stale_time time is stale, is_master $is_master", $master);
	
	# we are only worried about multiprocess NFS here ... if running not
	# in mod_perl mode, probably just CGI mounted on local disk
	# Only do this while in DESTROY() mode too, so we avoid Application_OnStart
	# hang behavior.
	if($^O !~ /Win/ && $ENV{MOD_PERL} && $self->{DESTROY}) {
	    $self->Debug("sleep for acquire master check in case of shared state");
	    sleep(1);
	}
	
	my $master = $internal->{CleanupMaster}; # recheck after flush
	my $is_master = (($master->{ServerID} eq $ServerID) and ($master->{PID} eq $$)) ? 1 : 0;
	$self->{dbg} && $self->Debug("is_master $is_master after update $ServerID - $$");
	$is_master;
    } elsif($is_master) {
	$master->{Checked} = time();
	$internal->{CleanupMaster} = $master;
	$internal->UNLOCK;
	$self->{dbg} && $self->Debug("$stale_time time is fresh, is_master $is_master", $master);
	1; # is master
    } else {
	$internal->UNLOCK;
	$self->{dbg} && $self->Debug("$stale_time time is fresh, is_master $is_master", $master);
	0; # not master
    }
}

# combo get / set
sub SessionId {
    my($self, $id) = @_;

    if(defined $id) {
	unless($self->{session_url_force}) {
	    # don't set the cookie when we are just using SessionQuery* configs
	    my $secure = $self->{secure_session} ? '; secure' : '';
	    $self->{r}->header_out('Set-Cookie', "$SessionCookieName=$id; path=$self->{cookie_path}".$secure);
	}
	$self->{session_id} = $id;
    } else {
	# if we have already parsed it out, return now
	# quick session_id caching, mostly for use with 
	# cookie less url building
	$self->{session_id} && return $self->{session_id};

	my $session_cookie = 0;

	unless($self->{session_url_force}) {
	    # don't read the cookie when we are just using SessionQuery* configs
	    my $cookie = $self->{r}->header_in("Cookie") || '';
	    my(@parts) = split(/\;\s*/, $cookie);
	    for(@parts) {	
		my($name, $value) = split(/\=/, $_, 2);
		if($name eq $SessionCookieName) {
		    $id = $value;
		    $session_cookie = 1;
		    $self->{dbg} && $self->Debug("session id from cookie: $id");
		    last;
		}
	    }
	}

	my $session_from_url;
	if(! defined($id) && $self->{session_url}) {
	    $id = delete $self->{Request}{QueryString}{$SessionCookieName};	    
	    # if there was more than one session id in the query string, then just
	    # take the first one
	    ref($id) =~ /ARRAY/ and ($id) = @$id;
	    $id && $self->{dbg} && $self->Debug("session id from query string: $id");
	    $session_from_url = 1;
	}

	# SANTIZE the id against hacking
	if(defined $id) {
	    if($id =~ /^[0-9a-z]{8,32}$/s) {
		# at least 8 bytes, but less than 32 bytes
		$self->{session_id} = $id;
	    } else {
		$self->Log("passed in session id $id failed checks sanity checks");
		$id = undef;		
	    }
	}

	if ($session_from_url && defined $id) {
	    $self->SessionId($id);
	}

	if(defined $id) {
	    $self->{session_id} = $id;
	    $self->{session_cookie} = $session_cookie;
	}
    }

    $id;
}

sub Secret {
    my $self = shift;
    # have enough data in here that even if srand() is seeded for the purpose
    # of debugging an external program, should have decent behavior.
    my $data = $self . $self->{remote_ip} . rand() . time() . 
      $self->{global} . $self->{'r'} . $self->{'filename'}.
	$$ . $ServerID;
    my $secret = substr(md5_hex($data), 0, $SessionIDLength);
    # by having [0-1][0-f] as the first 2 chars, only 32 groups now, which remains
    # efficient for inactive sites, even with empty groups
    $secret =~ s/^(.)/0/;
    $secret;
}

sub RefreshSessionId {
    my($self, $id, $reset) = @_;
    $id || $self->Error("no id for refreshing");
    my $internal = $self->{Internal};

    $internal->LOCK;
    my $idata = $internal->{$id};    
    my $refresh_timeout = $reset ? 
      $self->{session_timeout} : $idata->{refresh_timeout} || $self->{session_timeout};
    $idata->{'timeout'} = time() + $refresh_timeout;
    $internal->{$id} = $idata;	
    $internal->UNLOCK;
    $self->{dbg} && $self->Debug("refreshing $id with timeout $idata->{timeout}");

    1;
}

1;
