##############################################################################
#
# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""Caching tool implementation.

$Id: CachingPolicyManager.py 40256 2005-11-19 20:12:41Z rafrombrc $
"""

from AccessControl import ClassSecurityInfo
from App.Common import rfc1123_date
from DateTime.DateTime import DateTime
from Globals import DTMLFile
from Globals import InitializeClass
from Globals import PersistentMapping
from OFS.SimpleItem import SimpleItem
from Products.PageTemplates.Expressions import getEngine
from Products.PageTemplates.Expressions import SecureModuleImporter

from permissions import ManagePortal
from permissions import View
from Expression import Expression
from interfaces.CachingPolicyManager \
        import CachingPolicyManager as ICachingPolicyManager
from utils import _dtmldir
from utils import getToolByName

from zope.interface import implements
from interfaces import ICachingPolicy

def createCPContext( content, view_method, keywords, time=None ):
    """
        Construct an expression context for TALES expressions,
        for use by CachingPolicy objects.
    """
    pm = getToolByName( content, 'portal_membership', None )
    if not pm or pm.isAnonymousUser():
        member = None
    else:
        member = pm.getAuthenticatedMember()

    if time is None:
        time = DateTime()

    # The name "content" is deprecated and will go away in CMF 2.0,
    # please use "object" in your policy
    data = { 'content'  : content
           , 'object'   : content
           , 'view'     : view_method
           , 'keywords' : keywords
           , 'request'  : getattr( content, 'REQUEST', {} )
           , 'member'   : member
           , 'modules'  : SecureModuleImporter
           , 'nothing'  : None
           , 'time'     : time
           }

    return getEngine().getContext( data )


class CachingPolicy:
    """
        Represent a single class of cachable objects:

          - class membership is defined by 'predicate', a TALES expression
            with access to the following top-level names:

            'object' -- the object itself

            'view' -- the name of the view method

            'keywords' -- keywords passed to the request

            'request' -- the REQUEST object itself

            'member' -- the authenticated member, or None if anonymous

            'modules' -- usual TALES access-with-import

            'nothing' -- None

            'time' -- A DateTime object for the current date and time

          - mtime_func is used to set the "Last-modified" HTTP response
            header, which is another TALES expression evaluated
            against the same namespace.  If not specified explicitly,
            uses 'object/modified'.  mtime_func is also used in responding
            to conditional GETs.

          - The "Expires" HTTP response header and the "max-age" token of
            the "Cache-control" header will be set using 'max_age_secs',
            if passed;  it should be an integer value in seconds.

          - The "s-maxage" token of the "Cache-control" header will be
            set using 's_max_age_secs', if passed;  it should be an integer
            value in seconds.

          - The "Vary" HTTP response headers will be set if a value is 
            provided. The Vary header is described in RFC 2616. In essence,
            it instructs caches that respect this header (such as Squid
            after version 2.4) to distinguish between requests not just by
            the request URL, but also by values found in the headers showing
            in the Vary tag. "Vary: Cookie" would force Squid to also take 
            Cookie headers into account when deciding what cached object to 
            choose and serve in response to a request.

          - The "ETag" HTTP response header will be set if a value is
            provided. The value is a TALES expression and the result 
            after evaluation will be used as the ETag header value.

          - Other tokens will be added to the "Cache-control" HTTP response
            header as follows:

             'no_cache=1' argument => "no-cache" token

             'no_store=1' argument => "no-store" token

             'must_revalidate=1' argument => "must-revalidate" token

             'proxy_revalidate=1' argument => "proxy-revalidate" token
             
             'public=1' argument => "public" token
             
             'private=1' argument => "private" token

             'no_transform=1' argument => "no-transform" token

          - The last_modified argument is used to determine whether to add a
            Last-Modified header.  last_modified=1 by default.  There appears
            to be a bug in IE 6 (and possibly other versions) that uses the
            Last-Modified header plus some heuristics rather than the other
            explicit caching headers to determine whether to render content
            from the cache.  If you set, say, max-age=0, must-revalidate and
            have a Last-Modified header some time in the past, IE will
            recognize that the page in cache is stale and will request an
            update from the server BUT if you have a Last-Modified header
            with an older date, will then ignore the update and render from
            the cache, so you may want to disable the Last-Modified header
            when controlling caching using Cache-Control headers.

          - The pre-check and post-check Cache-Control tokens are Microsoft
            proprietary tokens added to IE 5+.  Documentation can be found
            here: http://msdn.microsoft.com/workshop/author/perf/perftips.asp
            Unfortunately these are needed to make IE behave correctly.

    """

    implements(ICachingPolicy)

    def __init__( self
                , policy_id
                , predicate=''
                , mtime_func=''
                , max_age_secs=None
                , no_cache=0
                , no_store=0
                , must_revalidate=0
                , vary=''
                , etag_func=''
                , s_max_age_secs=None
                , proxy_revalidate=0
                , public=0
                , private=0
                , no_transform=0
                , enable_304s=0
                , last_modified=1
                , pre_check=None
                , post_check=None
                ):

        if not predicate:
            predicate = 'python:1'

        if not mtime_func:
            mtime_func = 'object/modified'

        if max_age_secs is not None:
            if str(max_age_secs).strip() == '':
                max_age_secs = None
            else:
                max_age_secs = int( max_age_secs )

        if s_max_age_secs is not None:
            if str(s_max_age_secs).strip() == '':
                s_max_age_secs = None
            else:
                s_max_age_secs = int( s_max_age_secs )

        if pre_check is not None:
            if str(pre_check).strip() == '':
                pre_check = None
            else:
                pre_check = int(pre_check)
            
        if post_check is not None:
            if str(post_check).strip() == '':
                post_check = None
            else:
                post_check = int(post_check)

        self._policy_id = policy_id
        self._predicate = Expression( text=predicate )
        self._mtime_func = Expression( text=mtime_func )
        self._max_age_secs = max_age_secs
        self._s_max_age_secs = s_max_age_secs
        self._no_cache = int( no_cache )
        self._no_store = int( no_store )
        self._must_revalidate = int( must_revalidate )
        self._proxy_revalidate = int( proxy_revalidate )
        self._public = int( public )
        self._private = int( private )
        self._no_transform = int( no_transform )
        self._vary = vary
        self._etag_func = Expression( text=etag_func )
        self._enable_304s = int ( enable_304s )
        self._last_modified = int( last_modified )
        self._pre_check = pre_check
        self._post_check = post_check

    def getPolicyId( self ):
        """
        """
        return self._policy_id

    def getPredicate( self ):
        """
        """
        return self._predicate.text

    def getMTimeFunc( self ):
        """
        """
        return self._mtime_func.text

    def getMaxAgeSecs( self ):
        """
        """
        return self._max_age_secs

    def getSMaxAgeSecs( self ):
        """
        """
        return getattr(self, '_s_max_age_secs', None)

    def getNoCache( self ):
        """
        """
        return self._no_cache

    def getNoStore( self ):
        """
        """
        return self._no_store

    def getMustRevalidate( self ):
        """
        """
        return self._must_revalidate

    def getProxyRevalidate( self ):
        """
        """
        return getattr(self, '_proxy_revalidate', 0)

    def getPublic( self ):
        """
        """
        return getattr(self, '_public', 0)

    def getPrivate( self ):
        """
        """
        return getattr(self, '_private', 0)

    def getNoTransform( self ):
        """
        """
        return getattr(self, '_no_transform', 0)

    def getVary( self ):
        """
        """
        return getattr(self, '_vary', '')

    def getETagFunc( self ):
        """
        """
        etag_func_text = ''
        etag_func = getattr(self, '_etag_func', None)

        if etag_func is not None:
            etag_func_text = etag_func.text

        return etag_func_text

    def getEnable304s(self):
        """
        """
        return getattr(self, '_enable_304s', 0)

    def getLastModified(self):
        """Should we set the last modified header?"""
        return getattr(self, '_last_modified', 1)

    def getPreCheck(self):
        """
        """
        return getattr(self, '_pre_check', None)

    def getPostCheck(self):
        """
        """
        return getattr(self, '_post_check', None)
    
    def testPredicate(self, expr_context):
        """ Does this request match our predicate?"""
        return self._predicate(expr_context)

    def getHeaders( self, expr_context ):
        """
            Does this request match our predicate?  If so, return a
            sequence of caching headers as ( key, value ) tuples.
            Otherwise, return an empty sequence.
        """
        headers = []

        if self.testPredicate( expr_context ):

            if self.getLastModified():
                mtime = self._mtime_func( expr_context )
                if type( mtime ) is type( '' ):
                    mtime = DateTime( mtime )
                if mtime is not None:
                    mtime_str = rfc1123_date(mtime.timeTime())
                    headers.append( ( 'Last-modified', mtime_str ) )

            control = []

            if self.getMaxAgeSecs() is not None:
                now = expr_context.vars[ 'time' ]
                exp_time_str = rfc1123_date(now.timeTime() + self._max_age_secs)
                headers.append( ( 'Expires', exp_time_str ) )
                control.append( 'max-age=%d' % self._max_age_secs )
                
            if self.getSMaxAgeSecs() is not None:
                control.append( 's-maxage=%d' % self._s_max_age_secs )

            if self.getNoCache():
                control.append( 'no-cache' )
                # The following is for HTTP 1.0 clients
                headers.append(('Pragma', 'no-cache'))

            if self.getNoStore():
                control.append( 'no-store' )

            if self.getPublic():
                control.append( 'public' )

            if self.getPrivate():
                control.append( 'private' )

            if self.getMustRevalidate():
                control.append( 'must-revalidate' )

            if self.getProxyRevalidate():
                control.append( 'proxy-revalidate' )

            if self.getNoTransform():
                control.append( 'no-transform' )

            pre_check = self.getPreCheck()
            if pre_check is not None:
                control.append('pre-check=%d' % pre_check)

            post_check = self.getPostCheck()
            if post_check is not None:
                control.append('post-check=%d' % post_check)

            if control:
                headers.append( ( 'Cache-control', ', '.join( control ) ) )

            if self.getVary():
                headers.append( ( 'Vary', self._vary ) )

            if self.getETagFunc():
                headers.append( ( 'ETag', self._etag_func( expr_context ) ) )

        return headers



class CachingPolicyManager( SimpleItem ):
    """
        Manage the set of CachingPolicy objects for the site;  dispatch
        to them from skin methods.
    """

    __implements__ = ICachingPolicyManager

    id = 'caching_policy_manager'
    meta_type = 'CMF Caching Policy Manager'

    security = ClassSecurityInfo()

    def __init__( self ):
        self._policy_ids = ()
        self._policies = PersistentMapping()

    #
    #   ZMI
    #
    manage_options = ( ( { 'label'  : 'Policies'
                         , 'action' : 'manage_cachingPolicies'
                         , 'help'   : ('CMFCore', 'CPMPolicies.stx')
                         }
                       ,
                       )
                     + SimpleItem.manage_options
                     )

    security.declareProtected( ManagePortal, 'manage_cachingPolicies' )
    manage_cachingPolicies = DTMLFile( 'cachingPolicies', _dtmldir )

    security.declarePublic( 'listPolicies' )
    def listPolicies( self ):
        """
            Return a sequence of tuples,
            '( policy_id, ( policy, typeObjectName ) )'
            for all policies in the registry 
        """
        result = []
        for policy_id in self._policy_ids:
            result.append( ( policy_id, self._policies[ policy_id ] ) )
        return tuple( result )

    security.declareProtected( ManagePortal, 'addPolicy' )
    def addPolicy( self
                 , policy_id
                 , predicate           # TALES expr (def. 'python:1')
                 , mtime_func          # TALES expr (def. 'object/modified')
                 , max_age_secs        # integer, seconds (def. 0)
                 , no_cache            # boolean (def. 0)
                 , no_store            # boolean (def. 0)
                 , must_revalidate     # boolean (def. 0)
                 , vary                # string value
                 , etag_func           # TALES expr (def. '')
                 , REQUEST=None
                 , s_max_age_secs=None # integer, seconds (def. None)
                 , proxy_revalidate=0  # boolean (def. 0)
                 , public=0            # boolean (def. 0)
                 , private=0           # boolean (def. 0)
                 , no_transform=0      # boolean (def. 0)
                 , enable_304s=0       # boolean (def. 0)
                 , last_modified=1     # boolean (def. 1)
                 , pre_check=None      # integer, default None
                 , post_check=None     # integer, default None
                 ):
        """
            Add a caching policy.
        """
        if max_age_secs is None or str(max_age_secs).strip() == '':
            max_age_secs = None
        else:
            max_age_secs = int(max_age_secs)

        if s_max_age_secs is None or str(s_max_age_secs).strip() == '':
            s_max_age_secs = None
        else:
            s_max_age_secs = int(s_max_age_secs)
            
        if pre_check is None or str(pre_check).strip() == '':
            pre_check = None
        else:
            pre_check = int(pre_check)

        if post_check is None or str(post_check).strip() == '':
            post_check = None
        else:
            post_check = int(post_check)

        self._addPolicy( policy_id
                       , predicate
                       , mtime_func
                       , max_age_secs
                       , no_cache
                       , no_store
                       , must_revalidate
                       , vary
                       , etag_func
                       , s_max_age_secs
                       , proxy_revalidate
                       , public
                       , private
                       , no_transform
                       , enable_304s
                       , last_modified
                       , pre_check
                       , post_check
                       )
        if REQUEST is not None: 
            REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                                          + '/manage_cachingPolicies'
                                          + '?manage_tabs_message='
                                          + 'Policy+added.'
                                          )

    security.declareProtected( ManagePortal, 'updatePolicy' )
    def updatePolicy( self
                    , policy_id
                    , predicate           # TALES expr (def. 'python:1')
                    , mtime_func          # TALES expr (def. 'object/modified')
                    , max_age_secs        # integer, seconds (def. 0)
                    , no_cache            # boolean (def. 0)
                    , no_store            # boolean (def. 0)
                    , must_revalidate     # boolean (def. 0)
                    , vary                # string value
                    , etag_func           # TALES expr (def. '')
                    , REQUEST=None
                    , s_max_age_secs=None # integer, seconds (def. 0)
                    , proxy_revalidate=0  # boolean (def. 0)
                    , public=0            # boolean (def. 0)
                    , private=0           # boolean (def. 0)
                    , no_transform=0      # boolean (def. 0)
                    , enable_304s=0       # boolean (def. 0)
                    , last_modified=1     # boolean (def. 1)
                    , pre_check=0         # integer, default=None
                    , post_check=0        # integer, default=None
                    ):
        """
            Update a caching policy.
        """
        if max_age_secs is None or str(max_age_secs).strip() == '':
            max_age_secs = None
        else:
            max_age_secs = int(max_age_secs)

        if s_max_age_secs is None or str(s_max_age_secs).strip() == '':
            s_max_age_secs = None
        else:
            s_max_age_secs = int(s_max_age_secs)
            
        if pre_check is None or str(pre_check).strip() == '':
            pre_check = None
        else:
            pre_check = int(pre_check)

        if post_check is None or str(post_check).strip() == '':
            post_check = None
        else:
            post_check = int(post_check)

        self._updatePolicy( policy_id
                          , predicate
                          , mtime_func
                          , max_age_secs
                          , no_cache
                          , no_store
                          , must_revalidate
                          , vary
                          , etag_func
                          , s_max_age_secs
                          , proxy_revalidate
                          , public
                          , private
                          , no_transform
                          , enable_304s
                          , last_modified
                          , pre_check
                          , post_check
                          )
        if REQUEST is not None: 
            REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                                          + '/manage_cachingPolicies'
                                          + '?manage_tabs_message='
                                          + 'Policy+updated.'
                                          )

    security.declareProtected( ManagePortal, 'movePolicyUp' )
    def movePolicyUp( self, policy_id, REQUEST=None ):
        """
            Move a caching policy up in the list.
        """
        policy_ids = list( self._policy_ids )
        ndx = policy_ids.index( policy_id )
        if ndx == 0:
            msg = "Policy+already+first."
        else:
            self._reorderPolicy( policy_id, ndx - 1 )
            msg = "Policy+moved."
        if REQUEST is not None:
            REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                              + '/manage_cachingPolicies'
                              + '?manage_tabs_message=%s' % msg
                              )

    security.declareProtected( ManagePortal, 'movePolicyDown' )
    def movePolicyDown( self, policy_id, REQUEST=None ):
        """
            Move a caching policy down in the list.
        """
        policy_ids = list( self._policy_ids )
        ndx = policy_ids.index( policy_id )
        if ndx == len( policy_ids ) - 1:
            msg = "Policy+already+last."
        else:
            self._reorderPolicy( policy_id, ndx + 1 )
            msg = "Policy+moved."
        if REQUEST is not None:
            REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                              + '/manage_cachingPolicies'
                              + '?manage_tabs_message=%s' % msg
                              )

    security.declareProtected( ManagePortal, 'removePolicy' )
    def removePolicy( self, policy_id, REQUEST=None ):
        """
            Remove a caching policy.
        """
        self._removePolicy( policy_id )
        if REQUEST is not None:
            REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                              + '/manage_cachingPolicies'
                              + '?manage_tabs_message=Policy+removed.'
                              )

    #
    #   Policy manipulation methods.
    #
    security.declarePrivate( '_addPolicy' )
    def _addPolicy( self
                  , policy_id
                  , predicate
                  , mtime_func
                  , max_age_secs
                  , no_cache
                  , no_store
                  , must_revalidate
                  , vary
                  , etag_func
                  , s_max_age_secs=None
                  , proxy_revalidate=0
                  , public=0
                  , private=0
                  , no_transform=0
                  , enable_304s=0
                  , last_modified=1
                  , pre_check=None
                  , post_check=None
                  ):
        """
            Add a policy to our registry.
        """
        policy_id = str( policy_id ).strip()

        if not policy_id:
            raise ValueError, "Policy ID is required!"

        if policy_id in self._policy_ids:
            raise KeyError, "Policy %s already exists!" % policy_id

        self._policies[ policy_id ] = CachingPolicy( policy_id
                                                   , predicate
                                                   , mtime_func
                                                   , max_age_secs
                                                   , no_cache
                                                   , no_store
                                                   , must_revalidate
                                                   , vary
                                                   , etag_func
                                                   , s_max_age_secs
                                                   , proxy_revalidate
                                                   , public
                                                   , private
                                                   , no_transform
                                                   , enable_304s
                                                   , last_modified
                                                   , pre_check
                                                   , post_check
                                                   )
        idlist = list( self._policy_ids )
        idlist.append( policy_id )
        self._policy_ids = tuple( idlist )

    security.declarePrivate( '_updatePolicy' )
    def _updatePolicy( self
                     , policy_id
                     , predicate
                     , mtime_func
                     , max_age_secs
                     , no_cache
                     , no_store
                     , must_revalidate
                     , vary
                     , etag_func
                     , s_max_age_secs=None
                     , proxy_revalidate=0
                     , public=0
                     , private=0
                     , no_transform=0
                     , enable_304s=0
                     , last_modified=1
                     , pre_check=None
                     , post_check=None
                     ):
        """
            Update a policy in our registry.
        """
        if policy_id not in self._policy_ids:
            raise KeyError, "Policy %s does not exist!" % policy_id

        self._policies[ policy_id ] = CachingPolicy( policy_id
                                                   , predicate
                                                   , mtime_func
                                                   , max_age_secs
                                                   , no_cache
                                                   , no_store
                                                   , must_revalidate
                                                   , vary
                                                   , etag_func
                                                   , s_max_age_secs
                                                   , proxy_revalidate
                                                   , public
                                                   , private
                                                   , no_transform
                                                   , enable_304s
                                                   , last_modified
                                                   , pre_check
                                                   , post_check
                                                   )

    security.declarePrivate( '_reorderPolicy' )
    def _reorderPolicy( self, policy_id, newIndex ):
        """
            Reorder a policy in our registry.
        """
        if policy_id not in self._policy_ids:
            raise KeyError, "Policy %s does not exist!" % policy_id

        idlist = list( self._policy_ids )
        ndx = idlist.index( policy_id )
        pred = idlist[ ndx ]
        idlist = idlist[ :ndx ] + idlist[ ndx+1: ]
        idlist.insert( newIndex, pred )
        self._policy_ids = tuple( idlist )

    security.declarePrivate( '_removePolicy' )
    def _removePolicy( self, policy_id ):
        """
            Remove a policy from our registry.
        """
        if policy_id not in self._policy_ids:
            raise KeyError, "Policy %s does not exist!" % policy_id

        del self._policies[ policy_id ]
        idlist = list( self._policy_ids )
        ndx = idlist.index( policy_id )
        idlist = idlist[ :ndx ] + idlist[ ndx+1: ]
        self._policy_ids = tuple( idlist )


    #
    #   'portal_caching' interface methods
    #
    security.declareProtected( View, 'getHTTPCachingHeaders' )
    def getHTTPCachingHeaders( self, content, view_method, keywords, time=None):
        """
            Return a list of HTTP caching headers based on 'content',
            'view_method', and 'keywords'.
        """
        context = createCPContext( content, view_method, keywords, time=time )
        for policy_id, policy in self.listPolicies():

            headers = policy.getHeaders( context )
            if headers:
                return headers

        return ()

    security.declareProtected( View, 'getModTimeAndETag' )
    def getModTimeAndETag( self, content, view_method, keywords, time=None):
        """ Return the modification time and ETag for the content object,
            view method, and keywords as the tuple (modification_time, etag,
            set_last_modified_header), where modification_time is a DateTime,
            or None.
        """
        context = createCPContext( content, view_method, keywords, time=time )
        for policy_id, policy in self.listPolicies():
            if policy.getEnable304s() and policy.testPredicate(context):
                
                last_modified = policy._mtime_func(context)
                if type(last_modified) is type(''):
                    last_modified = DateTime(last_modified)

                content_etag = None
                if policy.getETagFunc():
                    content_etag = policy._etag_func(context)
                    
                return (last_modified, content_etag, policy.getLastModified())
            
        return None


InitializeClass( CachingPolicyManager )


def manage_addCachingPolicyManager( self, REQUEST=None ):
    """
        Add a CPM to self.
    """
    id = CachingPolicyManager.id
    mgr = CachingPolicyManager()
    self._setObject( id, mgr )

    if REQUEST is not None:
        REQUEST[ 'RESPONSE' ].redirect( self.absolute_url()
                      + '/manage_main'
                      + '?manage_tabs_message=Caching+Policy+Manager+added.'
                      )
