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