"""quixote.session

Quixote session management.  There are three levels to Quixote's
session management system:
  - SessionManager
  - Session
  - ApplicationState

A SessionManager is responsible for creating sessions, setting and reading
session cookies, maintaining the collection of all sessions, and so forth.
There is one SessionManager instance per Quixote process.

A Session is the umbrella object for a single session (notionally, a (user,
host, browser_process) triple).  Simple applications can probably get away
with putting all session data into a Session object (or, better, into an
application-specific subclass of Session).

Larger and more sophisticated applications, though, will want to create
ApplicationState subclasses.  These let you partition your session data
according to the major subdivisions of your web site.  Every Session
instances maintains a collection of ApplicationState instances -- actually,
instances of ApplicationState subclasses, since ApplicationState should not
be instantiated directly.  Each ApplicationState subclass will have at most
one instance in a given Session.

The default implementation provided here is not persistent: when the
Quixote process shuts down, all session data is lost.  You will need to
subclass SessionManager if you want to implement persistent sessions.
"""

__revision__ = "$Id: session.py,v 1.38 2001/09/06 16:10:33 nascheme Exp $"

import sys, string
from time import time, localtime, strftime, strptime, mktime, clock

from quixote import get_publisher
from quixote.errors import SessionError

# This is used both to generate cookies and parse them (when we need
# to figure out how old they are).
TIME_FORMAT = "%Y%m%d:%H%M%S"


def packbytes(s):
    "convert a string of bytes into a long integer"
    n = 0L
    for b in s:
        n <<= 8
        n |= ord(b)
    return n
    
try:
    # /dev/urandom is just as good as /dev/random for cookies (assuming
    # SHA-1 is secure) and it never blocks.
    open("/dev/urandom")
    def randlong(bytes):
        """Return bits of random data as a long integer."""
        return packbytes(open("/dev/urandom").read(bytes))

except IOError:
    # this is much less secure than the above function
    import sha
    _randstate = sha.new(str(time() + clock()))
    def randlong(bytes):
        """Return bits of random data as a long integer."""
        global _randstate
        s = ""
        while len(s) < bytes:
            _randstate.update(str(time() + clock()))
            s += _randstate.digest()
        return packbytes(s[:bytes])
        

class SessionManager:
    """
    SessionManager acts as a dictionary of all sessions, mapping
    session ID strings to individual session objects.  Session objects
    are instances of Session (or an application-specific subclass).
    SessionManager is also responsible for creating and destroying
    sessions, for generating and interpreting session cookies, and for
    session persistence (if any -- this implementation is not
    persistent).

    Most applications can just use this class directly; sessions will
    be kept in memory-based dictionaries, and will be lost when the
    Quixote process dies.  Alternatively an application can subclass
    SessionManager to implement specific behaviour, such as persistence.

    Instance attributes:
      sessions : { session_id:string : Session }
        the collection of sessions managed by this SessionManager

    """

    def __init__ (self):
        self.sessions = {}

    # Methods to be overridden by subclasses
    
    def abort(self):
        "Abort all changes to sessions"
        pass

    def commit(self):
        "Commit all changes to sessions"
        pass

    def new_session(self, request, id):
        "Return a new session object."
        # Subclasses can override this to return an instance of a different
        # class.
        return Session(request, id)

    
    # -- Dictionary methods --------------------------------------------

    def keys(self):
        return self.sessions.keys()

    def values(self):
        return self.sessions.values()

    def items(self):
        return self.sessions.items()

    def get(self, key):
        return self.sessions.get(key)

    def __getitem__(self, key):
        return self.sessions[key]

    def __delitem__ (self, key):
        del self.sessions[key]

    # and here's one real dictionaries *should* have ;-)
    def sorted_keys (self):
        k = self.sessions.keys()
        return k


    # -- Worker methods ------------------------------------------------
    # This is where the heavy lifting of session management
    # gets done.

    def get_session (self, request):
        """get_session(request : HTTPRequest) -> Session

        Fetch or create a session object for the current session, and
        return it.  If a session cookie is found in the HTTP request
        object 'request', uses it to look up and return an existing
        session object.  If no session cookie is found, or its value is
        bogus (ie. not an existing session), a new session is created.
        """
        config = get_publisher().config
        id = request.cookies.get(config.cookie_name)
        if id == "" or id == "*del*":
            id = None
        if id is not None:
            session = self.sessions.get(id)
            if session is None:
                # Note that it's important to revoke the session cookie
                # so the user doesn't keep getting "Expired session ID"
                # error pages.  However, it has to be done in the
                # response object for the error document, which doesn't
                # exist yet.  Thus it's handled by
                # Publisher.format_session_error().
                raise SessionError(session_id=id)
            
        if id is None or session is None:

            # Generate a "session ID", which is just the value of the
            # session cookie we are about to drop on the user
            while id is None or self.sessions.has_key(id):
                # IP address + date/time + big (64-bit) random number
                id = ("%s:%s:%016X" %
                      (request.get_environ("REMOTE_ADDR"),
                       strftime(TIME_FORMAT, localtime(time())),
                       randlong(8)))
                    
            # If we get here, it means this is the first we have seen of
            # this user in this session (or they've been rejecting our
            # cookies, which means we'll keep coming back here).  So, we
            # create a session object -- typically one per (user, host,
            # browser_process) triple
            session = self.new_session(request, id)

        session.access_time = time()
        return session

    # get_session ()

    def maintain_session (self, request, session):
        """maintain_session(request : HTTPRequest, session : Session)

        Maintain session information.  This method should be called after
        servicing a HTTP request, just before the response is returned.  If
        a session contains information it is saved and a cookie dropped on
        the client.  If not, it the session is discarded.  It the client has
        a cookie it is deleted.
        """
        if self.sessions.has_key(session.id):
            # session is already saved
            if session.is_empty():
                # no use keeping it around
                del self.sessions[session.id]
                self.revoke_session_cookie(request)
        else:
            # session is not saved
            if not session.is_empty():
                # must save it in the dictionary of sessions and drop
                # a cookie on the client so that it can be found again
                self.sessions[session.id] = session
                self.set_session_cookie(request, session.id)


    def set_session_cookie (self, request, session_id):
        """Ensure that a session cookie with value 'session_id' will be
        returned to the client via 'response', and store it in 'reqeust'
        so that the cookie is available to any further processing of
        this request.
        """
        assert self.sessions.has_key(session_id), \
               "invalid session ID %s" % `session_id`
        config = get_publisher().config
        request.response.set_cookie(config.cookie_name, session_id,
                                    domain = config.cookie_domain,
                                    path = config.cookie_path)


    def revoke_session_cookie (self, request):
        """Remove the session cookie with value 'session_id' from the
        remote user's session by resetting the value and maximum age in
        'response'.  Also remove the cookie from 'request' so that
        further processing of this request does not see the cookie's
        revoked value.
        """
        config = get_publisher().config
        response = request.response
        response.set_cookie(config.cookie_name, "",
                            domain = config.cookie_domain,
                            path = config.cookie_path,
                            max_age = 0)
        if request.cookies.has_key(config.cookie_name):
            del request.cookies[config.cookie_name]


    def has_session (self, request, must_exist=0):
        """Return true if the current request already has a cookie
        identifying a session object.  If 'must_exist' is true, the
        cookie must correspond to a currently existing session;
        otherwise (the default), we just check for the existence of the
        session cookie and don't inspect its content at all.
        """
        config = get_publisher().config
        id = request.cookies.get(config.cookie_name)
        if id is None:
            return 0
        if must_exist:
            return self.sessions.has_key(id)
        else:
            return 1


    def get_app_state (self, request, app_id):
        """Fetch or create an application state object for the
        application identified by 'app_id' and the current session.
        """
        session = self.get_session(request)
        return session.get_app_state(app_id)


    def del_sessions (self, ip=None, age=None):
        """Delete a subset of open sessions selected according to the
        keyword arguments.  Default values select (and delete) no
        sessions.  Sessions are selected by combining non-None keyword
        args with "and": eg. if 'ip' and 'age' are non-None then any
        session matching *both* the supplied IP address and age (hours
        since session creation) is deleted.
        
        'ip' specifies an IP address (dotted-quad string); it must match
        the IP address of a session exactly.  'age' is the session's age
        (since creation) in hours; any session older than 'age' will
        pass the age test. Again, sessions must pass all specified tests
        (non-None arguments) to be selected and deleted.
        """
        goners = []
        for id in self.sessions.keys():
            (s_ip, s_date, s_time, rand) = string.split (id, ':')
            if ip is not None and ip != s_ip:
                continue
            if age is not None:
                datetime = strptime ("%s:%s" % (s_date, s_time), TIME_FORMAT)
                datetime = mktime (datetime)
            if (time() - datetime) <= float (age):
                continue

            # passed all the tests: we'll delete this sucker
            goners.append (id)

        return goners

    # del_session

# SessionManager


class Session:
    """Holds session information that crosses application boundaries,
    and maintains a registry of application state objects where
    individual applications keep their particular session-state
    information.

    Instance attributes:
      id : string
        the session ID (generated by SessionManager and used as the
        value of the session cookie)
      actual_user : any
        an object to identify the human being on the other end of the
        line.  It's up to you whether to store just a string in 'user',
        or some more complex data structure or object.
      user : any
        the user object used for access control checks.  Usually .user ==
        .actual_user, but for a situation where a user can act on
        behalf of another, you would leave .actual_user alone and
        set .user to another user object.
      creation_time : float
      access_time : float
        two ways of keeping track of the "age" of the whole session
        (without knowing anything about the age of individual
        application state objects).  Note that 'access_time' is
        maintained by the SessionManager that owns this session.
      app_state : {string : ApplicationState}
        maps application IDs to application state objects (which should
        be instances of ApplicationState subclasses)
      app_creation_time : {string : float}
      app_access_time : {string : float}
        keep track of the creation and last-access time for each
        application state object in 'app_state'

    Feel free to access 'id', 'user', 'creation_time', and 'access_time'
    directly.  'id' and the '_time' attributes must never change;
    if 'user' or 'actual_user' need to change, use 'set_user()' or
    'set_actual_user()'.  All other attributes are private: do not
    access or modify them. 
    """


    # 'app_state_class' - registry of application state classes (so
    # we know how to create the objects that go in the per-instance
    # application state object registry)
    app_state_class = {}

    MAX_FORM_TOKENS = 16 # maximum number of outstanding form tokens

    def __init__ (self, request, id):

        self.id = id
        self.user = self.actual_user = None
        self.creation_time = self.access_time = time()
        self.form_tokens = [] # queue
        self.app_state = {}
        self.app_creation_time = {}
        self.app_access_time = {}

    def __repr__ (self):
        return "<%s at %x: %s>" % (self.__class__.__name__, id(self), self.id)

    def __str__ (self):
        if self.actual_user is None:
            return "session %s (no user)" % self.id
        elif self.user == self.actual_user:
            return "session %s (user %s)" % (self.id, self.user)
        else:
            return "session %s (user %s working as %s)" % (self.id,
                                                           self.actual_user,
                                                           self.user)
    

    def is_empty (self):
        """Return true if this session contains no information that must be
        saved."""
        if self.actual_user or self.user or self.form_tokens:
            return 0
        for app in self.app_state.values():
            if not app.is_empty():
                return 0
        else:
            return 1

    def dump (self, file=None, header=1, deep=1):
        time_fmt = "%Y-%m-%d %H:%M:%S"
        ctime = strftime(time_fmt, localtime(self.creation_time))
        atime = strftime(time_fmt, localtime(self.access_time))

        if header:
            file.write('session %s:' % self.id)
        file.write('  user %s' % self.user)
        file.write('  created %s, last accessed %s' % (ctime, atime))
        file.write('  form_tokens: %s\n' % self.form_tokens)

        if deep:
            apps = self.app_state.keys()
            if apps:
                file.write('  application state objects:')
                apps.sort()
                for app in apps:
                    ctime = self.app_creation_time.get(app)
                    if ctime is not None:
                        ctime = strftime(time_fmt, localtime(ctime))
                    atime = self.app_access_time.get(app)
                    if atime is not None:
                        atime = strftime(time_fmt, localtime(atime))
                    file.write('%s: created %s, last accessed %s' %
                                  (app, ctime, atime))
                    app_state = self.app_state[app]
                    try:
                        st_lines = app_state.dump()
                    except (AttributeError, NotImplementedError):
                        file.write(app_state._dump())
                    else:
                        file.write(st_lines)
            else:
                file.write('no application state objects')
    
    # dump()


    # -- Hooks into the Quixote main loop ------------------------------

    def start_request (self, request):
        """Called near the beginning of each request: after the HTTPRequest
        object has been built and this Session object has been fetched or
        built, but before we traverse the URL or call the callable object
        found by URL traversal.
        """
        if self.user:
            request.environ['REMOTE_USER'] = str(self.user)

    def finish_request (self, request):
        """Called near the end of each request: after a callable object
        has been found and (successfully) called.  Not called if there
        were any errors processing the request.
        """
        pass


     # -- Simple accessors and modifiers --------------------------------

    def set_actual_user (self, user):
        self.user = self.actual_user = user

    def set_user (self, user):
        self.user = user

    def get_creation_age (self):
        """Return the number of seconds since session was created."""
        return time() - self.creation_time

    def get_access_age (self):
        """Return the number of seconds since session was last accessed."""
        return time() - self.access_time


    # -- More involved stuff -------------------------------------------

    def get_app_state (self, app_id):

        state = self.app_state.get(app_id)
        if state is None:
            klass = self.app_state_class.get(app_id)
            if klass is None:
                raise RuntimeError, \
                      "no application state class has been registered " + \
                      "for application '%s'" % app_id

            state = klass ()
            # XXX restore this check (using mems.lib.base.isinstance?)!
            if 0 and not isinstance (state, ApplicationState):
                raise RuntimeError, \
                      ("class %s (for application '%s') " +
                       "not a subclass of ApplicationState") % (klass, app_id)

            self.app_state[app_id] = state
            self.app_creation_time[app_id] = \
                self.app_access_time[app_id] = time()

        else:
            self.app_access_time[app_id] = time()

        return state

    # get_app_state ()


    def create_form_token (self):
        """create_form_token() -> string

        Create a new form token and add it to a queue of outstanding form
        tokens for this session.  A maximum of MAX_FORM_TOKENS are saved.
        The new token is returned.
        """
        token = "%016X" % randlong(8)
        self.form_tokens.append(token)
        extra = len(self.form_tokens) - self.MAX_FORM_TOKENS
        if extra > 0:
            del self.form_tokens[:extra]
        return token

    def has_form_token (self, token):
        """has_form_token(token : string) -> boolean

        Return true if 'token' is in the queue of outstanding tokens.
        """
        return token in self.form_tokens
        
    def remove_form_token (self, token):
        """remove_form_token(token : string)

        Remove 'token' from the queue of outstanding tokens.
        """
        self.form_tokens.remove(token)

# Session


class ApplicationState:
    """Defines the interface that application state classes must
    implement.

    Instance attributes: none
    """
    
    def __init__ (self):
        """Constructor takes no arguments, and just initializes all
        application session state structures.
        """

    def is_empty (self):
        """Return true if the ApplicationState is empty (i.e. it has no
        information that must be saved).  Subclasses should override this.
        Sessions that contain empty application states may be dropped in
        order to save memory."""
        return 0 # unless a smart subclass says otherwise

    def cleanup (self):
        """Destroy all application state data in memory without regard
        to persistence: the goal here is to free up memory, not to
        commit session data anywhere.
        """

    def dump (self, indent='', header=1, deep=1):
        """Dump all relevant and interesting information in this
        application state object.  If 'header' is true (the default),
        the first line of the dump will identify the object, and the
        rest of the dump will be indented deeper than the header line.
        Otherwise, the initial line is skipped and the whole dump is
        indented by 'indent'.  If 'deep' is true, we'll dump contained
        objects that might be bulky or non-essential; otherwise, we'll
        skip dumping them.
        """
        
# ApplicationState


def register_app_state_class (klass):
    try:
        app_id = klass.app_id
    except AttributeError:
        raise RuntimeError, \
              "application state class '%s' must define 'app_id' attribute" % \
              klass.__name__
    Session.app_state_class[app_id] = klass


#
# Pair of functions for getting and setting the singleton SessionManager
# object.
# 

_session_mgr = None

def get_session_manager ():
    global _session_mgr
    return _session_mgr

def set_session_manager (manager):
    global _session_mgr
    if _session_mgr is not None:
        raise RuntimeError, "session manager already set"
    _session_mgr = manager


    
# These are the functions commonly used by callers.
def get_session (request):
    return get_session_manager().get_session(request)

def has_session (request=None, must_exist=0):
    return get_session_manager().has_session(request, must_exist)

def get_app_state (request, app_id):
    if app_id is None:
        raise RuntimeError, "app_id must be supplied"
    return get_session_manager().get_app_state(request, app_id)
