Revert rss20 to using $ENV{PATH_INFO} in self link.
[blosxom-plugins.git] / general / lastmodified2
blob65b542367a37b977c5a29fe9984f580b85d0b2a4
1 # Blosxom Plugin: lastmodified2
2 # Author(s): Frank Hecker <hecker@hecker.org>
3 # (based on work by Bob Schumaker <cobblers@pobox.com>)
4 # Version: 0.10
5 # Documentation: See the bottom of this file or type: perldoc lastmodified2
7 package lastmodified2;
9 use strict;
11 use HTTP::Date;
12 use Data::Dumper;
13 use POSIX qw! strftime !;
15 # Use the Digest:MD5 module if available, the older MD5 module if not.
17 my $use_digest;
18 my $use_just_md5;
20 BEGIN {
21     if (eval "require Digest::MD5") {
22         Digest::MD5->import();
23         $use_digest = 1;
24     }
25     elsif (eval "require MD5") {
26         MD5->import();
27         $use_just_md5 = 1;
28     }
31 # --- Package variables -----
33 my $current_time = time();              # Use consistent value of current time.
34 my $last_modified_time = 0;
35 my $etag = "";
36 my $md5_digest = "";
37 my %validator;
39 # --- Output variables -----
41 our $latest_rfc822 = '';
42 our $latest_iso8601 = '';
44 our $others_rfc822 = '';
45 our $others_iso8601 = '';
47 our $now_rfc822 = '';
48 our $now_iso8601 = '';
50 our $story_rfc822 = '';
51 our $story_iso8601 = '';
53 # --- Configurable variables -----
55 my $generate_etag = 1;                  # generate ETag header?
57 my $generate_mod = 1;                   # generate Last-modified header?
59 my $strong = 0;                         # do strong validation?
61 my $val_cache = "validator.cache";      # where to cache last-modified values
62                                         # and MD5 digests (in state directory)
64 my $generate_expires = 0;               # generate Expires header?
66 my $generate_cache = 0;                 # generate Cache-control header?
68 my $freshness_time = 3000;              # number of seconds pages are fresh
69                                         # (0 = do not cache, max is 1 year)
71 my $generate_length = 1;                # generate Content-length header?
73 my $use_others = 0;                     # consult %others for weak validation
74                                         # (DEPRECATED)
76 my $export_dates = 1;                   # set $latest_rfc822, etc., for
77                                         # compatibility with lastmodified
79 my $debug = 0;                          # set > 0 for debug output
81 # --------------------------------
84 # Do any initial processing, and decide whether to activate the plugin.
86 sub start {
87     warn "lastmodified2: start\n" if $debug > 1;
89     # Don't activate this plugin if we are doing static page generation.
91     return 0 if $blosxom::static_or_dynamic eq 'static';
93     # If we can't do MD5 then we don't do strong validation.
95     if ($strong && !($use_digest || $use_just_md5)) {
96         $strong = 0;
98         warn "lastmodified2: MD5 not available, forcing weak validation\n"
99             if $debug > 0;
100     }
102     # Limit freshness time to maximum of one year, must be non-negative.
104     $freshness_time > 365*24*3600 and $freshness_time = 365*24*3600;
105     $freshness_time < 0 and $freshness_time = 0;
107     if ($debug > 1) {
108         warn "lastmodified2: \$generate_etag = $generate_etag\n"; 
109         warn "lastmodified2: \$generate_mod = $generate_mod\n"; 
110         warn "lastmodified2: \$strong = $strong\n"; 
111         warn "lastmodified2: \$generate_cache = $generate_cache\n"; 
112         warn "lastmodified2: \$generate_expires = $generate_expires\n"; 
113         warn "lastmodified2: \$freshness_time = $freshness_time\n"; 
114         warn "lastmodified2: \$generate_length = $generate_length\n"; 
115     }
117     # If we are using Last-modified as a strong validator then read
118     # in the cached last-modified values and MD5 digests.
120     if ($generate_mod && $strong &&
121         open CACHE, "<$blosxom::plugin_state_dir/$val_cache" ) {
123         warn "lastmodified2: loading cached validators\n" if $debug > 0;
125         my $index = join '', <CACHE>;
126         close CACHE;
128         my $VAR1;
129         $index =~ m!\$VAR1 = \{!
130             and eval($index) and !$@ and %validator = %$VAR1;
131     }
133     # Convert current time to RFC 822 and ISO 8601 formats for others' use.
135     if ($export_dates && $current_time) {
136         $now_rfc822 = HTTP::Date::time2str($current_time);
137         $now_iso8601 = iso8601($current_time);
138     }
140     return 1;
144 # We check the list of entries to be displayed and determine the modification
145 # time of the most recent entry.
147 sub filter {
148     my ($pkg, $files, $others) = @_;
150     warn "lastmodified2: filter\n" if $debug > 1;
152     # We can skip all this unless we're doing weak validation and/or we're
153     # setting the *_rfc822 and *_iso8601 variables for others to use.
155     return 1 unless $export_dates ||
156         (($generate_etag || $generate_mod) && !$strong);
158     # Find the latest date/time modified for the entries to be displayed.
160     $last_modified_time = 0;
161     for (values %$files) {
162         $_ > $last_modified_time and $last_modified_time = $_;
163     }
165     warn "lastmodified2: \$last_modified_time = " .
166         $last_modified_time . " (entries)\n" if $debug > 0;
168     # Convert last modified time to RFC 822 and ISO 8601 formats for others.
170     if ($export_dates && $last_modified_time) {
171         $latest_rfc822 = HTTP::Date::time2str($last_modified_time);
172         $latest_iso8601 = iso8601($last_modified_time);
173     }
175     # Optionally look at other files as well (DEPRECATED).
177     if ($use_others) {
178         my $others_last_modified_time = 0;
179         for (values %$others) {
180             $_ > $others_last_modified_time
181                 and $others_last_modified_time = $_;
182         }
184         if ($export_dates && $others_last_modified_time) {
185             $others_rfc822 = HTTP::Date::time2str($others_last_modified_time);
186             $others_iso8601 = iso8601($others_last_modified_time);
187         }
189         warn "lastmodified2: \$others_last_modified_time = " .
190             $others_last_modified_time . " (others)\n" if $debug > 0;
192         $others_last_modified_time > $last_modified_time
193             and $last_modified_time = $others_last_modified_time;
194     }
196     # If we're doing weak validation then create an etag based on the latest
197     # date/time modified and mark it as weak (i.e., by prefixing it with 'W/').
199     if ($generate_etag && !$strong) {
200         $etag = 'W/"' . $last_modified_time . '"';
202         warn "lastmodified2: \$etag = $etag\n" if $debug > 0;
203     }
205     return 1;
209 # Skip story processing and generate configured headers now on a conditional
210 # GET request for which we don't need to return a full response.
212 sub skip {
213     warn "lastmodified2: skip\n" if $debug > 1;
215     # If we are doing strong validation then we can't skip story processing
216     # because we need all output in order to generate the proper etag and/or
217     # last-modified value.
219     return 0 unless ($generate_etag || $generate_mod) && !$strong;
221     # Otherwise we can check here whether we can send a 304 or not.
223     my $send_304 = check_for_304();
225     # If we don't need to return a full response on a conditional GET then
226     # set the HTTP status to 304 and generate headers as configured.
227     # (We have to do this here because the last subroutine won't be executed
228     # if we skip story processing.)
230     add_headers($send_304) if $send_304;
232     return $send_304;
236 # Set variables with story date/time in RFC 822 and ISO 8601 formats.
238 sub story {
239     my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
241     warn "lastmodified2: story (\$path = $path, \$filename = $filename)\n"
242         if $debug > 1;
244     if ($export_dates) {
245         $path ||= "";
247         my $timestamp =
248             $blosxom::files{"$blosxom::datadir$path/$filename.$blosxom::file_extension"};
250         warn "lastmodified2: \$timestamp = $timestamp\n" if $debug > 0;
252         $story_rfc822 = $timestamp ? HTTP::Date::time2str($timestamp) : '';
253         $story_iso8601 = $timestamp ? iso8601($timestamp) : '';
254     }
256     return 1;
260 # Do conditional GET checks if we couldn't do them before (i.e., we are
261 # doing strong validation and couldn't skip story processing) and output
262 # any configured headers plus a 304 status if appropriate.
264 sub last {
265     warn "lastmodified2: last\n" if $debug > 1;
267     # If some other plugin has set the HTTP status to a non-OK value then we
268     # don't attempt to do anything here, since it would probably be wrong.
270     return 1 if $blosxom::header->{'Status'} &&
271         $blosxom::header->{'Status'} !~ m!^200 !;
273     # If we are using ETag and/or Last-modified as a strong validator then
274     # we generate an entity tag from the MD5 message digest of the complete
275     # output. (We use the base-64 representation if possible because it is
276     # more compact than hex and hence saves a few bytes of bandwidth.)
278     if (($generate_etag || $generate_mod) && $strong) {
279         $md5_digest =
280             $use_digest ? Digest::MD5::md5_base64($blosxom::output)
281                         : MD5->hex_hash($blosxom::output);
282         $etag = '"' . $md5_digest . '"';
284         warn "lastmodified2: \$etag = $etag\n" if $debug > 0;
285     }
287     # If we are using Last-modified as a strong validator then we look up
288     # the cached MD5 digest for this URI, compare it to the current digest,
289     # and use the cached last-modified value if they match. Otherwise we set
290     # the last-modified value to just prior to the current time.
292     my $cache_tag = cache_tag();
293     my $update_cache = 0;
295     if ($generate_mod && $strong) {
296         if ($validator{$cache_tag} &&
297             $md5_digest eq $validator{$cache_tag}{'md5'}) {
298             $last_modified_time = $validator{$cache_tag}{'last-modified'};
299         } else {
300             $last_modified_time = $current_time - 5;
301             $validator{$cache_tag}{'last-modified'} = $last_modified_time;
302             $validator{$cache_tag}{'md5'} = $md5_digest;
303             $update_cache = 1;
304         }
306         warn "lastmodified2: \$last_modified_time = $last_modified_time\n"
307             if $debug > 0;
309     }
311     # Do conditional GET checks and output configured headers plus status.
313     my $send_304 = check_for_304();
314     add_headers($send_304);
316     # Update the validator cache if we need to. To minimize race conditions
317     # we write the cache as a temporary file and then rename it.
319     if ($update_cache) {
320         warn "lastmodified2: updating validator cache\n" if $debug > 0;
322         my $tmp_cache = "$val_cache-$$-$current_time";
324         if (open CACHE, ">$blosxom::plugin_state_dir/$tmp_cache") {
325             print CACHE Dumper \%validator;
326             close CACHE;
328             warn "lastmodified2: renaming $tmp_cache to $val_cache\n"
329                 if $debug > 1;
331             rename("$blosxom::plugin_state_dir/$tmp_cache",
332                    "$blosxom::plugin_state_dir/$val_cache")
333                 or warn "couldn't rename $blosxom::plugin_state_dir/$tmp_cache: $!\n";
334         } else {
335             warn "couldn't > $blosxom::plugin_state_dir/$tmp_cache: $!\n";
336         }
337     }
339     1;
343 # Check If-none-match and/or If-modified-since headers and return true if
344 # we can send a 304 (not modified) response instead of a normal response.
346 sub check_for_304 {
347     my $etag_send_304 = 0;
348     my $mod_send_304 = 0;
349     my $etag_request = 0;
350     my $mod_request = 0;
351     my $send_304 = 0;
353     warn "lastmodified2: check_for_304\n" if $debug > 1;
355     # For a conditional GET using the If-none-match header, compare the
356     # ETag value(s) in the header with the ETag value generated for the page,
357     # set $etag_send_304 true if we don't need to send a full response,
358     # and note that an etag value was included in the request.
360     if ($ENV{'HTTP_IF_NONE_MATCH'}) {
361         $etag_request = 1;
362         if ($generate_etag) {
363             my @inm_etags = split '\s*,\s*', $ENV{'HTTP_IF_NONE_MATCH'};
365             if ($debug > 0) {
366                 for (@inm_etags) {
367                     warn "lastmodified2: \$inm_etag = |" . $_ . "|\n";
368                 }
369             }
371             for (@inm_etags) {
372                 $etag eq $_ and $etag_send_304 = 1 and last;
373             }
374         }
375     }
377     # For a conditional GET using the If-modified-since header, compare the
378     # time in the header with the time any entry on the page was last modified,
379     # set $mod_send_304 true if we don't need to send a full response, and
380     # also note that a last-modified value was included in the request.
382     if ($ENV{'HTTP_IF_MODIFIED_SINCE'}) {
383         $mod_request = 1;
384         if ($generate_mod) {
385             my $ims_time =
386                 HTTP::Date::str2time($ENV{'HTTP_IF_MODIFIED_SINCE'});
388             warn "lastmodified2: \$ims_time = " . $ims_time . "\n"
389                 if $debug > 0;
391             $mod_send_304 = 1 if $last_modified_time <= $ims_time;
392         }
393     }
395     # If the request includes both If-none-match and If-modified-since then
396     # we don't send a 304 response unless both tests agree it should be sent,
397     # per section 13.3.4 of the HTTP 1.1 specification.
399     if ($etag_request && $mod_request) {
400         $send_304 = $etag_send_304 && $mod_send_304;
401     } else {
402         $send_304 = $etag_send_304 || $mod_send_304;
403     }
405     warn "lastmodified2: \$send_304 = " . $send_304 .
406             " \$etag_send_304 = " . $etag_send_304 .
407             " \$mod_send_304 = " . $mod_send_304 . "\n"
408         if $debug > 0;
410     return $send_304;
414 # Set status and add additional header(s) depending on the type of response.
416 sub add_headers {
417     my ($send_304) = @_;
419     warn "lastmodified2: add_headers (\$send_304 = $send_304)\n"
420         if $debug > 1;
422     # Set HTTP status and truncate output if we are sending a 304 response.
424     if ($send_304) {
425         $blosxom::header->{'Status'} = "304 Not Modified";
426         $blosxom::output = "";
428         warn "lastmodified2: Status: " .
429             $blosxom::header->{'Status'} . "\n" if $debug > 0;
430     }
432     # For the rules on what headers to generate for a 304 response, see
433     # section 10.3.5 of the HTTP 1.1 protocol specification.
435     # Last-modified is not returned on a 304 response.
437     if ($generate_mod && !$send_304) {
438         $blosxom::header->{'Last-modified'} =
439             HTTP::Date::time2str($last_modified_time);
441         warn "lastmodified2: Last-modified: " .
442             $blosxom::header->{'Last-modified'} . "\n" if $debug > 0;
443     }
445     # If we send ETag on a 200 response then we send it on a 304 as well.
447     if ($generate_etag) {
448         $blosxom::header->{'ETag'} = $etag;
450         warn "lastmodified2: ETag: " .
451             $blosxom::header->{'ETag'} . "\n" if $debug > 0;
452     }
454     # We send Expires for a 304 since its value is updated for each request.
456     if ($generate_expires) {
457         $blosxom::header->{'Expires'} = $freshness_time ?
458             HTTP::Date::time2str($current_time + $freshness_time) :
459             HTTP::Date::time2str($current_time - 60);
461         warn "lastmodified2: Expires: " .
462             $blosxom::header->{'Expires'} . "\n" if $debug > 0;
463     }
465     # We send Cache-control for a 304 response for consistency with Expires.
467     if ($generate_cache) {
468         $blosxom::header->{'Cache-control'} =
469             $freshness_time ? "max-age=" . $freshness_time
470                             : "no-cache";
472         warn "lastmodified2: Cache-control: " .
473             $blosxom::header->{'Cache-control'} . "\n" if $debug > 0;
474     }
476     # Content-length is not returned on a 304 response.
478     if ($generate_length && !$send_304) {
479         $blosxom::header->{'Content-length'} = length($blosxom::output);
481         warn "lastmodified2: Content-length: " .
482             $blosxom::header->{'Content-length'} . "\n" if $debug > 0;
483     }
487 # Generate a tag to look up the cached last-modified value and MD5 digest
488 # for this URI.
490 sub cache_tag {
491     # Start with the original URI from the request.
493     my $tag = $ENV{REQUEST_URI} || "";
495     # Add an "/index.flavour" for uniqueness unless it's already present.
497     unless ($tag =~ m!/index\.!) {
498         $tag .= '/' unless ($tag =~ m!/$!);
499         $tag .= "index.$blosxom::flavour";
500     }
502     return $tag;
506 # Convert time to ISO 8601 format (including time zone offset).
507 # (Format is YYYY-MM-DDThh:mm:ssTZD per http://www.w3.org/TR/NOTE-datetime)
509 sub iso8601 {
510     my ($timestamp) = @_;
511     my $tz_offset = strftime("%z", localtime());
512     $tz_offset = substr($tz_offset, 0, 3) . ":" . substr($tz_offset, 3, 5);
513     return strftime("%Y-%m-%dT%T", localtime($timestamp)) . $tz_offset;
519 __END__
521 =head1 NAME
523 Blosxom Plug-in: lastmodified2
525 =head1 SYNOPSIS
527 Enables caching and validation of dynamically-generated Blosxom pages
528 by generating C<ETag>, C<Last-modified>, C<Cache-control>, and/or
529 C<Expires> HTTP headers in the response and responding appropriately
530 to an C<If-none-match> and/or C<If-modified-since> header in the
531 request. Also generates a C<Content-length> header to support HTTP 1.0
532 persistent connections.
534 =head1 VERSION
536 0.10
538 =head1 AUTHOR
540 Frank Hecker <hecker@hecker.org>, http://www.hecker.org/ (based on
541 work by Bob Schumaker, <cobblers@pobox.com>, http://www.cobblers.net/blog/)
543 =head1 DESCRIPTION
545 This plugin enables caching and validation of dynamically-generated
546 Blosxom pages by web browsers, web proxies, feed aggregators, and
547 other clients by generating various cache-related HTTP headers in the
548 response and supporting conditional GET requests, as described
549 below. This can reduce excess network traffic and server load caused
550 by requests for RSS or Atom feeds or for web pages for popular entries
551 or categories.
553 =head1 INSTALLATION AND CONFIGURATION
555 Copy this plugin into your Blosxom plugin directory. You should not
556 normally need to rename the plugin; however see the discussion below.
558 Configurable variables specify how the plugin handles validation
559 (C<$generate_etag>, C<$generate_mod>, and C<$strong>), caching
560 (C<$generate_cache>, C<$generate_expires>, and C<$freshness_time>) and
561 whether or not to generate any other recommended headers
562 (C<$generate_length>). The plugin supports the variable C<$use_others>
563 as used in the lastmodified plugin; however use of this is deprecated
564 (use strong validation instead). The variable C<$export_dates>
565 specifies whether to export date/time variables C<$latest_rfc822>,
566 etc., for compatibility with the lastmodified plugin.
568 You can set the variable C<$debug> to 1 or greater to produce
569 additional information useful in debugging the operation of the
570 plugin; the debug output is sent to your web server's error log.
572 This plugin supplies C<filter>, C<skip>, and C<last> subroutines. It
573 needs to run after any other plugin whose C<filter> subroutine changes
574 the list of entries included in the response; otherwise the
575 C<Last-modified> date may be computed incorrectly. It needs to run
576 after any other plugin whose C<skip> subroutine does redirection
577 (e.g., the canonicaluri plugin) or otherwise conditionally sets the
578 HTTP status to any value other than 200. Finally, this plugin needs to
579 run after any other plugin whose C<last> subroutine changes the output
580 for the page; otherwise the C<Content-length> value (and the C<ETag>
581 and C<Last-modified> values, if you are using strong validation) may
582 be computed incorrectly. If you are encountering problems in any of
583 these regards then you can force the plugin to run after other plugins
584 by renaming it to, e.g., 99lastmodified2.
586 =head1 SEE ALSO
588 Blosxom Home/Docs/Licensing: http://blosxom.sourceforge.net/
590 Blosxom Plugin Docs: http://blosxom.sourceforge.net/documentation/users/plugins.html
592 lastmodified plugin: http://www.cobblers.net/blog/dev/blosxom/
594 more on the lastmodified2 plugin: http://www.hecker.org/blosxom/lastmodified2
596 =head1 BUGS
598 None known; please send bug reports and feedback to the Blosxom
599 development mailing list <blosxom-devel@lists.sourceforge.net>.
601 =head1 AUTHOR
603 Frank Hecker <hecker@hecker.org> http://www.hecker.org/
605 Based on the original lastmodified plugin by Bob Schumaker
606 <cobblers@pobox.com> http://www.cobblers.net/blog
608 This plugin is now maintained by the Blosxom Sourceforge Team,
609 <blosxom-devel@lists.sourceforge.net>.
611 =head1 LICENSE
613 This source code is submitted to the public domain.  Feel free to use
614 and modify it.  If you like, a comment in your modified source
615 attributing credit to myself, Bob Schumaker, and any other
616 contributors for our work would be appreciated.
618 THIS SOFTWARE IS PROVIDED AS IS AND WITHOUT ANY WARRANTY OF ANY KIND.
619 USE AT YOUR OWN RISK!