text: # -*- coding: utf-8 -*-
  #
  # Copyright (C) 2004-2014 Edgewall Software
  # Copyright (C) 2004 Daniel Lundin <daniel@edgewall.com>
  # Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
  # Copyright (C) 2006 Jonas Borgström <jonas@edgewall.com>
  # Copyright (C) 2008 Matt Good <matt@matt-good.net>
  # All rights reserved.
  #
  # This software is licensed as described in the file COPYING, which
  # you should have received as part of this distribution. The terms
  # are also available at http://trac.edgewall.org/wiki/TracLicense.
  #
  # This software consists of voluntary contributions made by many
  # individuals. For the exact contribution history, see the revision
  # history and logs, available at http://trac.edgewall.org/log/.
  #
  # Author: Daniel Lundin <daniel@edgewall.com>
  #         Christopher Lenz <cmlenz@gmx.de>
  
  import re
  import time
  
  from trac.admin.api import AdminCommandError, IAdminCommandProvider, \
                             console_date_format, get_console_locale
  from trac.core import Component, ExtensionPoint, TracError, implements
  from trac.util import hex_entropy, lazy
  from trac.util.datefmt import get_datetime_format_hint, format_date, \
                                parse_date, to_datetime, to_timestamp
  from trac.util.text import print_table
  from trac.util.translation import _
  from trac.web.api import IRequestHandler, is_valid_default_handler
  
  UPDATE_INTERVAL = 3600 * 24 # Update session last_visit time stamp after 1 day
  PURGE_AGE = 3600 * 24 * 90 # Purge session after 90 days idle
  COOKIE_KEY = 'trac_session'
  
  
  # Note: as we often manipulate both the `session` and the
  #       `session_attribute` tables, there's a possibility of table
  #       deadlocks (#9705). We try to prevent them to happen by always
  #       accessing the tables in the same order within the transaction,
  #       first `session`, then `session_attribute`.
  
  class DetachedSession(dict):
      def __init__(self, env, sid):
          dict.__init__(self)
          self.env = env
          self.sid = None
          if sid:
              self.get_session(sid, authenticated=True)
          else:
              self.authenticated = False
              self.last_visit = 0
              self._new = True
              self._old = {}
  
      def __setitem__(self, key, value):
          dict.__setitem__(self, key, unicode(value))
  
      def set(self, key, value, default=None):
          """Set a variable in the session, or remove it if it's equal to the
          default value.
          """
          value = unicode(value)
          if default is not None:
              default = unicode(default)
              if value == default:
                  self.pop(key, None)
                  return
          dict.__setitem__(self, key, value)
  
      def get_session(self, sid, authenticated=False):
          self.env.log.debug("Retrieving session for ID %r", sid)
  
          with self.env.db_query as db:
              self.sid = sid
              self.authenticated = authenticated
              self.clear()
  
              for last_visit, in db("""
                      SELECT last_visit FROM session
                      WHERE sid=%s AND authenticated=%s
                      """, (sid, int(authenticated))):
                  self._new = False
                  self.last_visit = int(last_visit or 0)
                  self.update(db("""
                      SELECT name, value FROM session_attribute
                      WHERE sid=%s and authenticated=%s
                      """, (sid, int(authenticated))))
                  self._old = self.copy()
                  break
              else:
                  self.last_visit = 0
                  self._new = True
                  self._old = {}
  
      def save(self):
          items = self.items()
          if not self._old and not items:
              # The session doesn't have associated data, so there's no need to
              # persist it
              return
  
          authenticated = int(self.authenticated)
          now = int(time.time())
  
          # We can't do the session management in one big transaction,
          # as the intertwined changes to both the session and
          # session_attribute tables are prone to deadlocks (#9705).
          # Therefore we first we save the current session, then we
          # eventually purge the tables.
  
          session_saved = False
  
          with self.env.db_transaction as db:
              # Try to save the session if it's a new one. A failure to
              # do so is not critical but we nevertheless skip the
              # following steps.
  
              if self._new:
                  self.last_visit = now
                  self._new = False
                  # The session might already exist even if _new is True since
                  # it could have been created by a concurrent request (#3563).
                  try:
                      db("""INSERT INTO session (sid, last_visit, authenticated)
                            VALUES (%s,%s,%s)
                            """, (self.sid, self.last_visit, authenticated))
                  except self.env.db_exc.IntegrityError:
                      self.env.log.warning('Session %s already exists', self.sid)
                      db.rollback()
                      return
  
              # Remove former values for session_attribute and save the
              # new ones. The last concurrent request to do so "wins".
  
              if self._old != self:
                  if self._old.get('name') != self.get('name') or \
                          self._old.get('email') != self.get('email'):
                      self.env.invalidate_known_users_cache()
                  if not items and not authenticated:
                      # No need to keep around empty unauthenticated sessions
                      db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                         (self.sid,))
                  db("""DELETE FROM session_attribute
                        WHERE sid=%s AND authenticated=%s
                        """, (self.sid, authenticated))
                  self._old = dict(self.items())
                  # The session variables might already have been updated by a
                  # concurrent request.
                  try:
                      db.executemany("""
                          INSERT INTO session_attribute
                            (sid,authenticated,name,value)
                          VALUES (%s,%s,%s,%s)
                          """, [(self.sid, authenticated, k, v)
                                for k, v in items])
                  except self.env.db_exc.IntegrityError:
                      self.env.log.warning('Attributes for session %s already '
                                           'updated', self.sid)
                      db.rollback()
                      return
                  session_saved = True
  
          # Purge expired sessions. We do this only when the session was
          # changed as to minimize the purging.
  
          if session_saved and now - self.last_visit > UPDATE_INTERVAL:
              self.last_visit = now
              mintime = now - PURGE_AGE
  
              with self.env.db_transaction as db:
                  # Update the session last visit time if it is over an
                  # hour old, so that session doesn't get purged
                  self.env.log.info("Refreshing session %s", self.sid)
                  db("""UPDATE session SET last_visit=%s
                        WHERE sid=%s AND authenticated=%s
                        """, (self.last_visit, self.sid, authenticated))
                  self.env.log.debug('Purging old, expired, sessions.')
                  db("""DELETE FROM session_attribute
                        WHERE authenticated=0 AND sid IN (
                            SELECT sid FROM session
                            WHERE authenticated=0 AND last_visit < %s
                        )
                        """, (mintime,))
  
              # Avoid holding locks on lot of rows on both session_attribute
              # and session tables
              with self.env.db_transaction as db:
                  db("""
                      DELETE FROM session
                      WHERE authenticated=0 AND last_visit < %s
                      """, (mintime,))
  
  
  class Session(DetachedSession):
      """Basic session handling and per-session storage."""
  
      def __init__(self, env, req):
          super(Session, self).__init__(env, None)
          self.req = req
          if req.authname == 'anonymous':
              if COOKIE_KEY not in req.incookie:
                  self.sid = hex_entropy(24)
                  self.bake_cookie()
              else:
                  sid = req.incookie[COOKIE_KEY].value
                  self.get_session(sid)
          else:
              if COOKIE_KEY in req.incookie:
                  sid = req.incookie[COOKIE_KEY].value
                  self.promote_session(sid)
              self.get_session(req.authname, authenticated=True)
  
      def bake_cookie(self, expires=PURGE_AGE):
          assert self.sid, 'Session ID not set'
          self.req.outcookie[COOKIE_KEY] = self.sid
          self.req.outcookie[COOKIE_KEY]['path'] = self.req.base_path or '/'
          self.req.outcookie[COOKIE_KEY]['expires'] = expires
          if self.env.secure_cookies:
              self.req.outcookie[COOKIE_KEY]['secure'] = True
          self.req.outcookie[COOKIE_KEY]['httponly'] = True
  
      _valid_sid_re = re.compile(r'[_A-Za-z0-9]+\Z')
  
      def get_session(self, sid, authenticated=False):
          refresh_cookie = False
  
          if not authenticated and not self._valid_sid_re.match(sid):
              raise TracError(_("Session ID must be alphanumeric."))
          if self.sid and sid != self.sid:
              refresh_cookie = True
  
          super(Session, self).get_session(sid, authenticated)
          if self.last_visit and time.time() - self.last_visit > UPDATE_INTERVAL:
              refresh_cookie = True
  
          # Refresh the session cookie if this is the first visit after a day
          if not authenticated and refresh_cookie:
              self.bake_cookie()
  
      def change_sid(self, new_sid):
          assert self.req.authname == 'anonymous', \
                 'Cannot change ID of authenticated session'
          assert new_sid, 'Session ID cannot be empty'
          if new_sid == self.sid:
              return
          if not self._valid_sid_re.match(new_sid):
              raise TracError(_("Session ID must be alphanumeric."),
                              _("Error renaming session"))
          with self.env.db_transaction as db:
              if db("SELECT sid FROM session WHERE sid=%s", (new_sid,)):
                  raise TracError(_("Session '%(id)s' already exists. "
                                    "Please choose a different session ID.",
                                    id=new_sid),
                                  _("Error renaming session"))
              self.env.log.debug("Changing session ID %s to %s", self.sid,
                                 new_sid)
              db("UPDATE session SET sid=%s WHERE sid=%s AND authenticated=0",
                 (new_sid, self.sid))
              db("""UPDATE session_attribute SET sid=%s
                    WHERE sid=%s and authenticated=0
                    """, (new_sid, self.sid))
          self.sid = new_sid
          self.bake_cookie()
  
      def promote_session(self, sid):
          """Promotes an anonymous session to an authenticated session, if there
          is no preexisting session data for that user name.
          """
          assert self.req.authname != 'anonymous', \
                 "Cannot promote session of anonymous user"
  
          with self.env.db_transaction as db:
              authenticated_flags = [authenticated for authenticated, in db(
                  "SELECT authenticated FROM session WHERE sid=%s OR sid=%s",
                  (sid, self.req.authname))]
  
              if len(authenticated_flags) == 2:
                  # There's already an authenticated session for the user,
                  # we simply delete the anonymous session
                  db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                     (sid,))
                  db("""DELETE FROM session_attribute
                        WHERE sid=%s AND authenticated=0
                        """, (sid,))
              elif len(authenticated_flags) == 1:
                  if not authenticated_flags[0]:
                      # Update the anonymous session records so the session ID
                      # becomes the user name, and set the authenticated flag.
                      self.env.log.debug("Promoting anonymous session %s to "
                                         "authenticated session for user %s",
                                         sid, self.req.authname)
                      db("""UPDATE session SET sid=%s, authenticated=1
                            WHERE sid=%s AND authenticated=0
                            """, (self.req.authname, sid))
                      db("""UPDATE session_attribute SET sid=%s, authenticated=1
                            WHERE sid=%s
                            """, (self.req.authname, sid))
              else:
                  # We didn't have an anonymous session for this sid. The
                  # authenticated session might have been inserted between the
                  # SELECT above and here, so we catch the error.
                  try:
                      db("""INSERT INTO session (sid, last_visit, authenticated)
                            VALUES (%s, %s, 1)
                            """, (self.req.authname, int(time.time())))
                  except self.env.db_exc.IntegrityError:
                      self.env.log.warning('Authenticated session for %s '
                                           'already exists', self.req.authname)
                      db.rollback()
          self._new = False
  
          self.sid = sid
          self.bake_cookie(0)  # expire the cookie
  
  
  class SessionAdmin(Component):
      """trac-admin command provider for session management"""
  
      implements(IAdminCommandProvider)
  
      request_handlers = ExtensionPoint(IRequestHandler)
  
      def get_admin_commands(self):
          hints = {
              'datetime': get_datetime_format_hint(get_console_locale(self.env)),
              'iso8601': get_datetime_format_hint('iso8601'),
          }
          yield ('session list', '[sid[:0|1]] [...]',
                 """List the name and email for the given sids
  
                 Specifying the sid 'anonymous' lists all unauthenticated
                 sessions, and 'authenticated' all authenticated sessions.
                 '*' lists all sessions, and is the default if no sids are
                 given.
  
                 An sid suffix ':0' operates on an unauthenticated session with
                 the given sid, and a suffix ':1' on an authenticated session
                 (the default).""",
                 self._complete_list, self._do_list)
  
          yield ('session add', '<sid[:0|1]> [name] [email]',
                 """Create a session for the given sid
  
                 Populates the name and email attributes for the given session.
                 Adding a suffix ':0' to the sid makes the session
                 unauthenticated, and a suffix ':1' makes it authenticated (the
                 default if no suffix is specified).""",
                 None, self._do_add)
  
          yield ('session set', '<name|email|default_handler> '
                                '<sid[:0|1]> <value>',
                 """Set the name or email attribute of the given sid
  
                 An sid suffix ':0' operates on an unauthenticated session with
                 the given sid, and a suffix ':1' on an authenticated session
                 (the default).""",
                 self._complete_set, self._do_set)
  
          yield ('session delete', '<sid[:0|1]> [...]',
                 """Delete the session of the specified sid
  
                 An sid suffix ':0' operates on an unauthenticated session with
                 the given sid, and a suffix ':1' on an authenticated session
                 (the default). Specifying the sid 'anonymous' will delete all
                 anonymous sessions.""",
                 self._complete_delete, self._do_delete)
  
          yield ('session purge', '<age>',
                 """Purge anonymous sessions older than the given age or date
  
                 Age may be specified as a relative time like "90 days ago", or
                 as a date in the "%(datetime)s" or "%(iso8601)s" (ISO 8601)
                 format.""" % hints,
                 None, self._do_purge)
  
      @lazy
      def _valid_default_handlers(self):
          return sorted(handler.__class__.__name__
                        for handler in self.request_handlers
                        if is_valid_default_handler(handler))
  
      def _split_sid(self, sid):
          if sid.endswith(':0'):
              return sid[:-2], 0
          elif sid.endswith(':1'):
              return sid[:-2], 1
          else:
              return sid, 1
  
      def _get_sids(self):
          rows = self.env.db_query("SELECT sid, authenticated FROM session")
          return ['%s:%d' % (sid, auth) for sid, auth in rows]
  
      def _get_list(self, sids):
          all_anon = 'anonymous' in sids or '*' in sids
          all_auth = 'authenticated' in sids or '*' in sids
          sids = set(self._split_sid(sid) for sid in sids
                     if sid not in ('anonymous', 'authenticated', '*'))
          rows = self.env.db_query("""
              SELECT DISTINCT s.sid, s.authenticated, s.last_visit,
                              n.value, e.value, h.value
              FROM session AS s
                LEFT JOIN session_attribute AS n
                  ON (n.sid=s.sid AND n.authenticated=s.authenticated
                      AND n.name='name')
                LEFT JOIN session_attribute AS e
                  ON (e.sid=s.sid AND e.authenticated=s.authenticated
                      AND e.name='email')
                LEFT JOIN session_attribute AS h
                  ON (h.sid=s.sid AND h.authenticated=s.authenticated
                      AND h.name='default_handler')
              ORDER BY s.sid, s.authenticated
              """)
          for sid, authenticated, last_visit, name, email, handler in rows:
              if all_anon and not authenticated or all_auth and authenticated \
                      or (sid, authenticated) in sids:
                  yield (sid, authenticated,
                         format_date(to_datetime(last_visit),
                                     console_date_format),
                         name, email, handler)
  
      def _complete_list(self, args):
          all_sids = self._get_sids() + ['*', 'anonymous', 'authenticated']
          return set(all_sids) - set(args)
  
      def _complete_set(self, args):
          if len(args) == 1:
              return ['name', 'email']
          elif len(args) == 2:
              return self._get_sids()
  
      def _complete_delete(self, args):
          all_sids = self._get_sids() + ['anonymous']
          return set(all_sids) - set(args)
  
      def _do_list(self, *sids):
          if not sids:
              sids = ['*']
          headers = (_("SID"), _("Auth"), _("Last Visit"), _("Name"),
                     _("Email"), _("Default Handler"))
          print_table(self._get_list(sids), headers)
  
      def _do_add(self, sid, name=None, email=None):
          sid, authenticated = self._split_sid(sid)
          with self.env.db_transaction as db:
              try:
                  db("INSERT INTO session VALUES (%s, %s, %s)",
                     (sid, authenticated, int(time.time())))
              except Exception:
                  raise AdminCommandError(_("Session '%(sid)s' already exists",
                                            sid=sid))
              if name is not None:
                  db("INSERT INTO session_attribute VALUES (%s,%s,'name',%s)",
                      (sid, authenticated, name))
              if email is not None:
                  db("INSERT INTO session_attribute VALUES (%s,%s,'email',%s)",
                      (sid, authenticated, email))
          self.env.invalidate_known_users_cache()
  
      def _do_set(self, attr, sid, val):
          if attr not in ('name', 'email', 'default_handler'):
              raise AdminCommandError(_("Invalid attribute '%(attr)s'",
                                        attr=attr))
          if attr == 'default_handler':
              if val and val not in self._valid_default_handlers:
                  raise AdminCommandError(_("Invalid default_handler '%(val)s'",
                                            val=val))
          sid, authenticated = self._split_sid(sid)
          with self.env.db_transaction as db:
              if not db("""SELECT sid FROM session
                           WHERE sid=%s AND authenticated=%s""",
                           (sid, authenticated)):
                  raise AdminCommandError(_("Session '%(sid)s' not found",
                                            sid=sid))
              db("""
                  DELETE FROM session_attribute
                  WHERE sid=%s AND authenticated=%s AND name=%s
                  """, (sid, authenticated, attr))
              db("INSERT INTO session_attribute VALUES (%s, %s, %s, %s)",
                 (sid, authenticated, attr, val))
          self.env.invalidate_known_users_cache()
  
      def _do_delete(self, *sids):
          with self.env.db_transaction as db:
              for sid in sids:
                  sid, authenticated = self._split_sid(sid)
                  if sid == 'anonymous':
                      db("DELETE FROM session WHERE authenticated=0")
                      db("DELETE FROM session_attribute WHERE authenticated=0")
                  else:
                      db("""
                          DELETE FROM session
                          WHERE sid=%s AND authenticated=%s
                          """, (sid, authenticated))
                      db("""
                          DELETE FROM session_attribute
                          WHERE sid=%s AND authenticated=%s
                          """, (sid, authenticated))
          self.env.invalidate_known_users_cache()
  
      def _do_purge(self, age):
          when = parse_date(age, hint='datetime',
                            locale=get_console_locale(self.env))
          with self.env.db_transaction as db:
              ts = to_timestamp(when)
              db("""
                  DELETE FROM session
                  WHERE authenticated=0 AND last_visit<%s
                  """, (ts,))
              db("""
                  DELETE FROM session_attribute
                  WHERE authenticated=0
                        AND sid NOT IN (SELECT sid FROM session
                                        WHERE authenticated=0)
                  """)
  