# ==================================================================
# Gossamer Threads Module Library - http://gossamer-threads.com/
#
#   GT::Session::SQL
#   Author: Alex Krohn
#   CVS Info : 087,071,086,086,085      
#   $Id: SQL.pm,v 1.36 2006/07/25 04:27:50 brewt Exp $
#
# Copyright (c) 2004 Gossamer Threads Inc.  All Rights Reserved.
# ==================================================================
#
# Description:
#   A module for implementing session management in SQL.
#   Note that it requires a table with the following columns:
#       session_id   - must be CHAR(32) BINARY
#       session_user_id
#       session_date - must be INT
#       session_data

package GT::Session::SQL;
# ===============================================================
# Pragmas
use strict;
use vars qw(@ISA $VERSION $DEBUG $ATTRIBS $ERRORS $error $DIRECTORY);

# Internal nodules
use GT::Base ();

# Global variable init
@ISA     = qw/GT::Base/;
$VERSION = sprintf "%d.%03d", q$Revision: 1.36 $ =~ /(\d+)\.(\d+)/;
$DEBUG   = 0;

$ATTRIBS = {
    info => {
        session_date    => undef,
        session_data    => undef,
        session_id      => undef,
        session_user_id => undef
    },
    tb           => undef,
    _debug       => $DEBUG,
    expires      => 4
};

$ERRORS  = {
    BADDATA        => "Invalid data in session: '%s'. Reason: '%s'",
    CLASSFUNC      => "This is a class function only.",
    INVALIDSESSION => "Invalid session id: '%s'.",
    BADARGS        => "Invalid arguments: %s"
};

sub new {
# ---------------------------------------------------------------
# Initilizes a session. Expects to find a session id to lookup, some
# data to save, or nothing. If no session is defined, then one will
# be generated. If an invalid session is specified, nothing is returned.
#
    my $this  = shift;
    my $class = ref $this || $this;
    my $self  = bless {}, $class;

# Set defaults.
    foreach (keys %$ATTRIBS) {
        $self->{$_} = ref $ATTRIBS->{$_} eq 'HASH'
            ? {%{$ATTRIBS->{$_}}}
            : $ATTRIBS->{$_};
    }

# We got passed in a single session id.
    if (@_ == 2) {
        $self->{tb} = $_[1];
        $self->load($_[0]) or return $self->error('INVALIDSESSION', 'WARN', $_[0]);
        $self->{save} = 0;
        return $self;
    }

# We got passed some options, possibly a session id.
    my $suggested;
    if (@_ == 1 and ref $_[0] eq 'HASH') {
        my $opts  = $_[0];
        foreach (keys %{$opts}) {
            if (exists $self->{$_}) { $self->{$_} = $opts->{$_} }
            elsif (exists $self->{info}->{$_}) { $self->{info}->{$_} = $opts->{$_} }
            elsif ($_ eq 'suggested_sid') { $suggested = $opts->{$_}; }
        }
    }

    exists($self->{tb}) or return $self->error("BADARGS", "FATAL", "Must pass in a table object");

# If we have an id, load it or return.
    if ($self->{info}->{session_id}) {
        $self->load($self->{info}->{session_id}) or return $self->error('INVALIDSESSION', 'WARN', $self->{info}->{session_id});
        $self->{save} = 0;
    }
    else {
        my $sid;
        $sid = defined $suggested ? $suggested : generate_session_id();
        while ($self->{tb}->count({ session_id => $sid }) > 0) {
            $sid = generate_session_id();
        }
        $self->{info}->{session_id} = $sid;
        $self->{save} = 1;
    }

    return $self;
}

DESTROY {
# ---------------------------------------------------------------
# Makes sure we save the session.
#
    local $SIG{__WARN__};
    my $self = shift;
    $self->save() if $self->{save};
    $self->debug("Object destroyed.") if $self->{_debug} and $self->{_debug} > 1;
}

sub data {
# ---------------------------------------------------------------
# Set/retrieve the data, make sure to set save to 1.
#
    my $self = shift;
    if (@_ >= 1) {
        $self->{info}->{session_data} = shift;
        $self->{save} = 1;
    }
    return $self->{info}->{session_data};
}

sub load {
# ---------------------------------------------------------------
# Loads a session id and data. Also updates the date if the 
# session is valid
#
    my ($self, $sid) = @_;
    if (defined($sid) and $sid =~ /^\w{1,32}$/) {
        my $sth = $self->{tb}->select({ session_id => $sid }) or return $self->error($GT::SQL::error);
        my $ret = $sth->fetchrow_hashref;
        if (!$sth->rows or !$ret) {
            $self->debug("Attempted to load invalid or expired session: '$sid'.") if $self->{_debug};
            return;
        }

# For backwards compatibility - session_expires isn't a required column
# The only two instances where the date doesn't need to be checked is if
# expires is 0, or the session_expires column exists and it is 0
        my $check_date = 1;
        if (not $self->{expires} or (exists $ret->{session_expires} and not $ret->{session_expires})) {
            $check_date = 0;
        }
        if ($check_date and $ret->{session_date} < time - $self->{expires} * 60 * 60) {
            $self->debug("Attempted to load expired session: '$sid'.") if $self->{_debug};
            return;
        }

        my $cp = {};
        for (keys %{$self->{info}}) {
            if ($_ eq 'session_data') {
                if (defined $self->{info}->{session_data}) {
                    require GT::Dumper;
                    $cp->{session_data} = GT::Dumper->dump(
                        var      => '',
                        compress => 1,
                        data     => $self->{info}->{session_data},
                    );
                }
            }
            else {
                $cp->{$_} = $self->{info}->{$_};
            }
        }
        if (exists $ret->{session_data}) {
            my $ev = delete $ret->{session_data};
            local ($@, $SIG{__DIE__});
            $self->{info}->{session_data} = eval $ev;
            $@ and return $self->error('BADDATA', 'FATAL', $sid, "$@");
        }
        for (keys %$ret) {
            $self->{info}->{$_} = $ret->{$_};
            $cp->{$_} = $ret->{$_} unless defined $cp->{$_};
        }
        $cp->{session_date} = time;
        my $s = delete $cp->{session_id};
        $self->{tb}->update($cp, { session_id => $s }) or return $self->error($GT::SQL::error);
    }
    else {
        $self->debug("Attempted to load invalid, or blank session '" . (defined($sid) ? $sid : '[undefined]') . ".") if $self->{_debug};
        return;
    }
    return 1;
}

sub save {
# ---------------------------------------------------------------
# Save a session id and data.
#
    my $self = shift;
    my $sid = $self->{info}->{session_id};

    if ($sid =~ /^\w{1,32}$/ and (defined $self->{info}->{session_user_id} or defined $self->{info}->{session_data})) {
        require GT::Dumper;
        my $data = GT::Dumper->dump(
            var  => '',
            data => $self->{info}->{session_data},
            compress => 1 # Eliminates whitespace and changes => to , to shrink the dump
        );
        my $info = {%{$self->{info}}}; # Copy $self->{info}
        $info->{session_data} = $data;
        $info->{session_date} = time;
        if ($self->{tb}->count({ session_id => $sid })) {
            delete $info->{session_id};
            # need to do an update instead of an insert because it already exists
            $self->{tb}->update($info, { session_id => $sid }) or return $self->error($GT::SQL::error);
            $self->debug("Changes to session '$sid' saved.") if $self->{_debug};
        }
# It doesn't exist, so insert
        else {
# For backwards compatibility - session_expires isn't a required column
            if (exists $self->{tb}->cols->{session_expires}) {
                $info->{session_expires} = $self->{expires} ? 1 : 0;
            }
            $self->{tb}->insert($info) or return $self->error($GT::SQL::error);
            $self->debug("Session '$sid' created and saved.") if $self->{_debug};
        }
        $self->{save} = 0;
    }
    else {
        $self->debug("Attempted to save invalid session '$sid'") if $self->{_debug};
    }
}

sub delete {
# ---------------------------------------------------------------
# Delete a session.
#
    my $self = shift;
    my $sid = $self->{info}->{session_id};

    if ($sid =~ /^\w{1,32}$/) {
        $self->{tb}->delete({ session_id => $sid }) or return $self->error($GT::SQL::error);

        $self->{info}->{session_id}   = undef;
        $self->{info}->{session_data} = undef;
        $self->{save} = 0;
        $self->debug("Session '$sid' deleted.") if $self->{_debug};
    }
    else {
        $self->debug("Attempted to delete an invalid session '$sid'") if $self->{_debug};
        return;
    }
    return 1;
}

sub cleanup {
# ---------------------------------------------------------------
# Method to cleanup sessions.
#
# Takes an optional arguments - the session timeout (in seconds).
# If omitted, $self->{expires} will be used for the timeout.
#
    my $self = shift;

    my $seconds;
    $seconds = @_ ? shift : $self->{expires} * 60 * 60;

    unless ($seconds) {
        $self->debug("cleanup not deleting anything, seconds set to 0.") if $self->{_debug};
        return;
    }

    my $cond = GT::SQL::Condition->new(session_date => '<' => time - $seconds);
# For backwards compatibility - session_expires isn't a required column
    if (exists $self->{tb}->cols->{session_expires}) {
        $cond->add(session_expires => '=' => 1);
    }

    $self->{tb}->delete($cond) or return $self->error($GT::SQL::error);
}

sub generate_session_id {
# ---------------------------------------------------------------
# Generates a session id.
#
    require GT::MD5;
    GT::MD5::md5_hex(rand(16000) . (time() ^ ($$ + ($$ << 15))) . $$);
}

1;
