1# See bottom of file for license and copyright information
2
3=begin TML
4
5---+ package Foswiki::PageCache
6
7This class is a purely virtual base class that implements the
8basic infrastructure required to cache pages as produced by
9the rendering engine. Once a page was computed, it will be
10cached for subsequent calls for the same output. In addition
11a Foswiki::PageCache has to ensure cache correctness, that is
12all content stored in the cache is up-to-date. It must not
13return any content being rendered on the base of  data that has already
14changed in the meantine by actions performed by the Foswiki::Store.
15
16The Foswiki::Store informs the cache whenever any content has changed
17by calling Foswiki::PageCache::fireDependency($web, $topic). This
18will in turn delete any cache entries that used this $web.$topic as an
19ingredience to render the cached page. That's why there is a dependency
20graph part of the page cache.
21
22The dependency graph records all topics that have been touched while
23the current page is being computed. It also records the session and url
24parameters that were in use, part of which is the user name as well.
25
26An edge in the dependency graph consists of:
27
28   * from: the topic being rendered
29   * variation: an opaque key encoding the context in which the page was rendered
30   * to: the topic that has been used to render the "from" topic
31
32For every cached page there's a record of meta data describing it:
33
34   * topic: the web.topic being cached
35   * variation: the context which this page was rendered within
36   * md5: fingerprint of the data stored; this is used to get access to the stored
37     blob related to this page
38   * contenttype: to be used in the http header
39   * lastmodified: time when this page was cached in http-date format
40   * etag: tag used for browser-side caching
41   * status: http response status
42   * location: url in case the status is a 302 redirect
43   * expire: time when this cache entry is outdated
44   * isdirty: boolean flag indicating whether the cached page has got "dirtyareas"
45     and thus needs post-processing
46
47Whenever the Foswiki::Store informs the cache by firing a dependency for
48a given web.topic, the cache will remove those cache entries that have a dependency
49to the given web.topic. It thereby guarentees that whenever a page has been
50successfully retrieved from the cache, there is no "fresher" content available
51in the Foswiki::Store, and that this cache entry can be used instead without
52rendering the related yet again.
53
54=cut
55
56package Foswiki::PageCache;
57
58use strict;
59use warnings;
60use Foswiki::Time    ();
61use Foswiki::Attrs   ();
62use Foswiki::Plugins ();
63use Error qw( :try );
64use CGI::Util ();
65
66BEGIN {
67    if ( $Foswiki::cfg{UseLocale} ) {
68        require locale;
69        import locale();
70    }
71}
72
73# Enable output
74use constant TRACE => 0;
75
76=begin TML
77
78---++ ClassMethod new( ) -> $object
79
80Construct a new page cache
81
82=cut
83
84sub new {
85    my ($class) = @_;
86
87    return bless( {}, $class );
88}
89
90=begin TML
91
92---++ ObjectMethod genVariationKey() -> $key
93
94Generate a key for the current webtopic being produced; this reads
95information from the current session and url params, as follows:
96    * the server serving the request (HTTP_HOST)
97    * the port number of the server serving the request (HTTP_PORT)
98    * the action used to render the page (view or rest)
99    * the language of the current session, if any
100    * all session parameters EXCEPT:
101          o Those starting with an underscore
102          o VALIDATION
103          o REMEMBER
104          o FOSWIKISTRIKEONE.*
105          o VALID_ACTIONS.*
106          o BREADCRUMB_TRAIL
107          o DGP_hash
108    * all HTTP request parameters EXCEPT:
109          o All those starting with an underscore
110          o refresh
111          o foswiki_redirect_cache
112          o logout
113          o topic
114          o cache_ignore
115          o cache_expire
116
117=cut
118
119sub genVariationKey {
120    my $this = shift;
121
122    my $variationKey = $this->{variationKey};
123    return $variationKey if defined $variationKey;
124
125    my $session    = $Foswiki::Plugins::SESSION;
126    my $request    = $session->{request};
127    my $action     = substr( ( $request->{action} || 'view' ), 0, 4 );
128    my $serverName = $session->{urlHost} || $Foswiki::cfg{DefaultUrlHost};
129    my $serverPort = $request->server_port || 80;
130    $variationKey = '::' . $serverName . '::' . $serverPort . '::' . $action;
131
132    # add a flag to distinguish compressed from uncompressed cache entries
133    $variationKey .= '::'
134      . (
135        (
136                 $Foswiki::cfg{HttpCompress}
137              && $Foswiki::engine->isa('Foswiki::Engine::CLI')
138        )
139        ? 1
140        : 0
141      );
142
143    # add language tag
144    if ( $Foswiki::cfg{UserInterfaceInternationalisation} ) {
145        my $language = $session->i18n->language();
146        $variationKey .= "::language=$language" if $language;
147    }
148
149    # get information from the session object
150    my $sessionValues = $session->getLoginManager()->getSessionValues();
151    foreach my $key ( sort keys %$sessionValues ) {
152
153      # SMELL: add a setting to make exclusion of session variables configurable
154        next
155          if $key =~
156m/^(_.*|VALIDATION|REMEMBER|FOSWIKISTRIKEONE.*|VALID_ACTIONS.*|BREADCRUMB_TRAIL|DGP_hash|release_lock)$/;
157
158        #writeDebug("adding session key=$key");
159
160        my $val = $sessionValues->{$key};
161        next unless defined $val;
162
163        $variationKey .= '::' . $key . '=' . $val;
164    }
165
166    # get cache_ignore pattern
167    my @ignoreParams = $request->multi_param("cache_ignore");
168    if ( defined $Foswiki::cfg{Cache}{ParamFilterList} ) {
169        push @ignoreParams,
170          split( /\s*,\s*/, $Foswiki::cfg{Cache}{ParamFilterList} );
171    }
172    else {
173        # Defaults for older foswiki
174        push @ignoreParams,
175          (
176            "cache_expire",           "cache_ignore",
177            "_.*",                    "refresh",
178            "foswiki_redirect_cache", "logout",
179            "validation_key",         "topic",
180            "redirectedfrom"
181          );
182    }
183    my $ignoreParams = join( "|", @ignoreParams );
184
185    foreach my $key ( sort $request->multi_param() ) {
186
187        # filter out some params that are not relevant
188        next if $key =~ m/^($ignoreParams)$/;
189        my @vals = $request->multi_param($key);
190        foreach my $val (@vals) {
191            next unless defined $val;    # wtf?
192            $variationKey .= '::' . $key . '=' . $val;
193            Foswiki::Func::writeDebug("adding urlparam key=$key val=$val")
194              if TRACE;
195        }
196    }
197
198    $variationKey =~ s/'/\\'/g;
199
200    Foswiki::Func::writeDebug("variation key = '$variationKey'") if TRACE;
201
202    # cache it
203    $this->{variationKey} = $variationKey;
204    return $variationKey;
205}
206
207=begin TML
208
209---++ ObjectMethod cachePage($contentType, $data) -> $boolean
210
211Cache a page. Every page is stored in a page bucket that contains all
212variations (stored for other users or other session parameters) of this page,
213as well as dependency and expiration information
214
215=cut
216
217sub cachePage {
218    my ( $this, $contentType, $data ) = @_;
219
220    my $session = $Foswiki::Plugins::SESSION;
221    my $request = $session->{request};
222    my $web     = $session->{webName};
223    my $topic   = $session->{topicName};
224    $web =~ s/\//./g;
225
226    Foswiki::Func::writeDebug("called cachePage($web, $topic)") if TRACE;
227    return undef unless $this->isCacheable( $web, $topic );
228
229    # delete page and all variations if we ask for a refresh copy
230    my $refresh = $request->param('refresh') || '';
231    my $variationKey = $this->genVariationKey();
232
233    # remove old entries.  Note refresh=all handled in getPage
234    if ( $refresh =~ m/^(on|cache)$/ ) {
235        $this->deletePage( $web, $topic );    # removes all variations
236    }
237    else {
238        $this->deletePage( $web, $topic, $variationKey );
239    }
240
241    # prepare page variation
242    my $isDirty =
243      ( $data =~ m/<dirtyarea[^>]*?>/ )
244      ? 1
245      : 0;    # SMELL: only for textual content type
246
247    Foswiki::Func::writeDebug("isDirty=$isDirty") if TRACE;
248
249    my $etag         = '';
250    my $lastModified = '';
251    my $time         = time();
252
253    unless ($isDirty) {
254        $data =~ s/([\t ]?)[ \t]*<\/?(nop|noautolink)\/?>/$1/gis;
255
256        # clean pages are stored utf8-encoded, whether plaintext or zip
257        $data = Foswiki::encode_utf8($data);
258        if ( $Foswiki::cfg{HttpCompress} ) {
259
260            # Cache compressed page
261            require Compress::Zlib;
262            $data = Compress::Zlib::memGzip($data);
263        }
264        $etag = $time;
265        $lastModified = Foswiki::Time::formatTime( $time, '$http', 'gmtime' );
266    }
267
268    my $headers   = $session->{response}->headers();
269    my $status    = $headers->{Status} || 200;
270    my $variation = {
271        contenttype  => $contentType,
272        lastmodified => $lastModified,
273        data         => $data,
274        etag         => $etag,
275        isdirty      => $isDirty,
276        status       => $status,
277    };
278    $variation->{location} = $headers->{Location} if $status == 302;
279
280    # get cache-expiry preferences and add it to the bucket if available
281    my $expire = $request->param("cache_expire");
282    $expire = $session->{prefs}->getPreference('CACHEEXPIRE')
283      unless defined $expire;
284    $variation->{expire} = CGI::Util::expire_calc($expire)
285      if defined $expire;
286
287    if ( defined $variation->{expire} && $variation->{expire} !~ /^\d+$/ ) {
288        print STDERR
289"WARNING: expire value '$variation->{expire}' is not recognized as a proper cache expiration value\n";
290        $variation->{expire} = undef;
291    }
292
293    # store page variation
294    Foswiki::Func::writeDebug("PageCache: Stored data") if TRACE;
295    return undef
296      unless $this->setPageVariation( $web, $topic, $variationKey, $variation );
297
298    # assert newly autotetected dependencies
299    $this->setDependencies( $web, $topic, $variationKey );
300
301    return $variation;
302}
303
304=begin TML
305
306---++ ObjectMethod getPage($web, $topic)
307
308Retrieve a cached page for the given web.topic, using a variation
309key based on the current session.
310
311=cut
312
313sub getPage {
314    my ( $this, $web, $topic ) = @_;
315
316    $web =~ s/\//./g;
317
318    Foswiki::Func::writeDebug("getPage($web.$topic)") if TRACE;
319
320    # check url param
321    my $session = $Foswiki::Plugins::SESSION;
322    my $refresh = $session->{request}->param('refresh') || '';
323    if ( $refresh eq 'all' ) {
324
325        if ( $session->{users}->isAdmin( $session->{user} ) ) {
326            $this->deleteAll();
327            return undef;
328        }
329        else {
330            my $session = $Foswiki::Plugins::SESSION;
331            my $request = $session->{request};
332            my $action  = substr( ( $request->{action} || 'view' ), 0, 4 );
333            unless ( $action eq 'rest' ) {
334                throw Foswiki::OopsException(
335                    'accessdenied',
336                    def   => 'cache_refresh',
337                    web   => $web,
338                    topic => $topic,
339                );
340            }
341        }
342    }
343
344    if ( $refresh eq 'fire' ) {    # simulates a "save" of the current topic
345        $this->fireDependency( $web, $topic );
346    }
347
348    if ( $refresh =~ m/^(on|cache)$/ ) {
349        $this->deletePage( $web, $topic );    # removes all variations
350    }
351
352    # check cacheability
353    return undef unless $this->isCacheable( $web, $topic );
354
355    # check availability
356    my $variationKey = $this->genVariationKey();
357
358    my $variation = $this->getPageVariation( $web, $topic, $variationKey );
359
360    # check expiry date of this entry; return undef if it did expire, not
361    # deleted from cache as it will be recomputed during a normal view
362    # cycle
363    return undef
364      if defined($variation)
365      && defined( $variation->{expire} )
366      && $variation->{expire} < time();
367
368    return $variation;
369}
370
371=begin TML
372
373---++ ObjectMethod setPageVariation($web, $topici, $variationKey, $variation) -> $bool
374
375stores a rendered page
376
377=cut
378
379sub setPageVariation {
380    my ( $this, $web, $topic, $variationKey, $variation ) = @_;
381
382    die("virtual method");
383}
384
385=begin TML
386
387---++ ObjectMethod getPageVariation($web, $topic, $variationKey)
388
389retrievs a cache entry; returns undef if there is none.
390
391=cut
392
393sub getPageVariation {
394    die("virtual method");
395}
396
397=begin TML
398
399Checks whether the current page is cacheable. It first
400checks the "refresh" url parameter and then looks out
401for the "CACHEABLE" preference variable.
402
403=cut
404
405sub isCacheable {
406    my ( $this, $web, $topic ) = @_;
407
408    my $webTopic = $web . '.' . $topic;
409
410    my $isCacheable = $this->{isCacheable}{$webTopic};
411    return $isCacheable if defined $isCacheable;
412
413    #Foswiki::Func::writeDebug("... checking") if TRACE;
414
415    # by default we try to cache as much as possible
416    $isCacheable = 1;
417
418    my $session = $Foswiki::Plugins::SESSION;
419    $isCacheable = 0 if $session->inContext('command_line');
420
421    # check for errors parsing the url path
422    $isCacheable = 0 if $session->{invalidWeb} || $session->{invalidTopic};
423
424    # POSTs and HEADs aren't cacheable
425    if ($isCacheable) {
426        my $method = $session->{request}->method;
427        $isCacheable = 0 if $method && $method =~ m/^(?:POST|HEAD)$/;
428    }
429
430    # check prefs value
431    if ($isCacheable) {
432        my $flag = $session->{prefs}->getPreference('CACHEABLE');
433        $isCacheable = 0 if defined $flag && !Foswiki::isTrue($flag);
434    }
435
436    # don't cache 401 Not authorized responses
437    if ($isCacheable) {
438        my $headers = $session->{response}->headers();
439        my $status  = $headers->{Status};
440        $isCacheable = 0 if $status && $status eq 401;
441    }
442
443    # TODO: give plugins a chance - create a callback to intercept cacheability
444
445    #Foswiki::Func::writeDebug("isCacheable=$isCacheable") if TRACE;
446    $this->{isCacheable}{$webTopic} = $isCacheable;
447    return $isCacheable;
448}
449
450=begin TML
451
452---++ ObjectMethod addDependencyForLink($web, $topic)
453
454Add a reference to a web.topic to the dependencies of the current page.
455
456Topic references, unlike hard dependencies, may cause internal links - WikiWords
457to render incorrectly unless the cache is cleared when the topic changes.
458(i.e, link to a missing topic, or render as a "new link" for a newly existing topic).
459
460This routine is configurable using {Cache}{TrackInternalLinks}.  By default, it treats
461all topic references as simple dependencies.  If disabled, link references are ignored,
462but if set to authenticated, links are tracked only for logged in users.
463
464=cut
465
466sub addDependencyForLink {
467    my ( $this, $webRef, $topicRef ) = @_;
468
469#Foswiki::Func::writeDebug( "addDependencyForLink $webRef.$topicRef\n" ) if TRACE;
470
471    my $session = $Foswiki::Plugins::SESSION;
472
473    return $this->addDependency( $webRef, $topicRef )
474      if ( !defined $Foswiki::cfg{Cache}{TrackInternalLinks}
475        || ( $Foswiki::cfg{Cache}{TrackInternalLinks} eq 'on' )
476        || ( $Foswiki::cfg{Cache}{TrackInternalLinks} eq 'authenticated' )
477        && $session->inContext('authenticated') );
478
479    # If we reach here, either:
480    #   - It is a guest session and TrackInternalLinks was set to authenticated
481    #   - TrackInternalLinks is set to off (or some unexpected value.
482    return;
483}
484
485=begin TML
486
487---++ ObjectMethod addDependency($web, $topic)
488
489Add a web.topic to the dependencies of the current page
490
491=cut
492
493sub addDependency {
494    my ( $this, $depWeb, $depTopic ) = @_;
495
496    # exclude invalid topic names
497    return unless $depTopic =~ m/^[[:upper:]]/;
498
499    # omit dependencies triggered from inside a dirtyarea
500    my $session = $Foswiki::Plugins::SESSION;
501    return if $session->inContext('dirtyarea');
502
503    $depWeb =~ s/\//\./g;
504    my $depWebTopic = $depWeb . '.' . $depTopic;
505
506    # exclude unwanted dependencies
507    if ( $depWebTopic =~ m/^($Foswiki::cfg{Cache}{DependencyFilter})$/ ) {
508
509#Foswiki::Func::writeDebug( "dependency on $depWebTopic ignored by filter $Foswiki::cfg{Cache}{DependencyFilter}") if TRACE;
510        return;
511    }
512    else {
513
514#Foswiki::Func::writeDebug("addDependency($depWeb.$depTopic) by" . ( caller() )[1] ) if TRACE;
515    }
516
517    # collect them; defer writing them to the database til we cache this page
518    $this->{deps}{$depWebTopic} = 1;
519}
520
521=begin TML
522
523---++ ObjectMethod getDependencies($web, $topic, $variationKey) -> \@deps
524
525Return dependencies for a given web.topic. if $variationKey is specified, only
526dependencies of this page variation will be returned.
527
528=cut
529
530sub getDependencies {
531    my ( $this, $web, $topic, $variationKey ) = @_;
532
533    die("virtual method");
534
535}
536
537=begin TML
538
539---++ ObjectMethod getWebDependencies($web) -> \@deps
540
541Returns dependencies that hold for all topics in a web.
542
543=cut
544
545sub getWebDependencies {
546    my ( $this, $web ) = @_;
547
548    unless ( defined $this->{webDeps} ) {
549        my $session = $Foswiki::Plugins::SESSION;
550        my $webDeps =
551             $session->{prefs}->getPreference( 'WEBDEPENDENCIES', $web )
552          || $Foswiki::cfg{Cache}{WebDependencies}
553          || '';
554
555        $this->{webDeps} = ();
556
557        # normalize topics
558        foreach my $dep ( split( /\s*,\s*/, $webDeps ) ) {
559            my ( $depWeb, $depTopic ) =
560              $session->normalizeWebTopicName( $web, $dep );
561
562            Foswiki::Func::writeDebug("found webdep $depWeb.$depTopic")
563              if TRACE;
564            $this->{webDeps}{ $depWeb . '.' . $depTopic } = 1;
565        }
566    }
567    my @result = keys %{ $this->{webDeps} };
568    return \@result;
569}
570
571=begin TML
572
573---++ ObjectMethod setDependencies($web, $topic, $variation, @topics)
574
575Stores the dependencies for the given web.topic topic. Setting the dependencies
576happens at the very end of a rendering process of a page while it is about
577to be cached.
578
579When the optional @topics parameter isn't provided, then all dependencies
580collected in the Foswiki::PageCache object will be used. These dependencies
581are collected during the rendering process.
582
583=cut
584
585sub setDependencies {
586    my ( $this, $web, $topic, $variationKey, @topicDeps ) = @_;
587
588    @topicDeps = keys %{ $this->{deps} } unless @topicDeps;
589
590    die("virtual method");
591}
592
593=begin TML
594
595---++ ObjectMethod deleteDependencies($web, $topic, $variation, $force)
596
597Remove a dependency from the graph. This operation is normally performed
598as part of a call to Foswiki::PageCache::deletePage().
599
600=cut
601
602sub deleteDependencies {
603    die("virtual method");
604}
605
606=begin TML
607
608---++ ObjectMethod deletePage($web, $topic, $variation, $force)
609
610Remove a page from the cache; this removes all of the information
611that we have about this page, including any dependencies that have
612been established while this page was created.
613
614If $variation is specified, only this variation of $web.$topic will
615be removed. When $variation is not specified, all page variations of $web.$topic
616will be removed.
617
618When $force is true, the deletion will take place immediately. Otherwise all
619delete requests might be delayed and committed as part of
620Foswiki::PageCache::finish().
621
622=cut
623
624sub deletePage {
625    die("virtual method");
626}
627
628=begin TML
629
630---++ ObjectMethod deleteAll()
631
632purges all of the cache
633
634=cut
635
636sub deleteAll {
637    die("virtual method");
638}
639
640=begin TML
641
642---++ ObjectMethod fireDependency($web, $topic)
643
644This method is called to remove all other cache entries that
645used the given $web.$topic as an ingredience to produce the page.
646
647A dependency is a directed edge starting from a page variation being rendered
648towards a depending page that has been used to produce it.
649
650While dependency edges are stored as they are collected during the rendering
651process, these edges are traversed in reverse order when a dependency is
652fired.
653
654In addition all manually asserted dependencies of topics in a web are deleted,
655as well as the given topic itself.
656
657=cut
658
659sub fireDependency {
660    die("virtual method");
661}
662
663=begin TML
664
665---++ ObjectMethod renderDirtyAreas($text)
666
667Extract dirty areas and render them; this happens after storing a
668page including the un-rendered dirty areas into the cache and after
669retrieving it again.
670
671=cut
672
673sub renderDirtyAreas {
674    my ( $this, $text ) = @_;
675
676    Foswiki::Func::writeDebug("called renderDirtyAreas") if TRACE;
677
678    my $session = $Foswiki::Plugins::SESSION;
679    $session->enterContext('dirtyarea');
680
681    # remember the current page length to recompute the content length below
682    my $found = 0;
683    my $topicObj =
684      new Foswiki::Meta( $session, $session->{webName}, $session->{topicName} );
685
686    # expand dirt
687    while ( $$text =~
688s/<dirtyarea([^>]*?)>(?!.*<dirtyarea)(.*?)<\/dirtyarea>/$this->_handleDirtyArea($1, $2, $topicObj)/ges
689      )
690    {
691        $found = 1;
692    }
693
694    $$text =~ s/([\t ]?)[ \t]*<\/?(nop|noautolink)\/?>/$1/gis if $found;
695
696    # remove any dirtyarea leftovers
697    $$text =~ s/<\/?dirtyarea>//g;
698
699    $session->leaveContext('dirtyarea');
700}
701
702# called by renderDirtyAreas() to process each dirty area in isolation
703sub _handleDirtyArea {
704    my ( $this, $args, $text, $topicObj ) = @_;
705
706    Foswiki::Func::writeDebug("called _handleDirtyArea($args)")
707      if TRACE;
708
709    #Foswiki::Func::writeDebug("in text=$text") if TRACE;
710
711    # add dirtyarea params
712    my $params  = new Foswiki::Attrs($args);
713    my $session = $Foswiki::Plugins::SESSION;
714    my $prefs   = $session->{prefs};
715
716    $prefs->pushTopicContext( $topicObj->web, $topicObj->topic );
717    $params->remove('_RAW');
718    $prefs->setSessionPreferences(%$params);
719    try {
720        $text = $topicObj->expandMacros($text);
721        $text = $topicObj->renderTML($text);
722    };
723    finally {
724        $prefs->popTopicContext();
725    };
726
727    my $request = $session->{request};
728    my $context = $request->url( -full => 1, -path => 1, -query => 1 ) . time();
729    my $cgis    = $session->{users}->getCGISession();
730    my $usingStrikeOne = $Foswiki::cfg{Validation}{Method} eq 'strikeone';
731
732    $text =~
733s/<input type='hidden' name='validation_key' value='(\?.*?)' \/>/Foswiki::Validation::updateValidationKey($cgis, $context, $usingStrikeOne, $1)/gei;
734
735    #Foswiki::Func::writeDebug("out text='$text'") if TRACE;
736    return $text;
737}
738
739=begin TML
740
741---++ ObjectMethod finish()
742
743clean up finally
744
745=cut
746
747sub finish {
748}
749
7501;
751__END__
752Foswiki - The Free and Open Source Wiki, http://foswiki.org/
753
754Copyright (C) 2008-2013 Foswiki Contributors. Foswiki Contributors
755are listed in the AUTHORS file in the root of this distribution.
756NOTE: Please extend that file, not this notice.
757
758This program is free software; you can redistribute it and/or
759modify it under the terms of the GNU General Public License
760as published by the Free Software Foundation; either version 2
761of the License, or (at your option) any later version. For
762more details read LICENSE in the root of this distribution.
763
764This program is distributed in the hope that it will be useful,
765but WITHOUT ANY WARRANTY; without even the implied warranty of
766MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
767
768As per the GPL, removal of this notice is prohibited.
769