tagging: Allow using titles in for related stories.
[matthijs/upstream/blosxom-plugins.git] / general / lastmodified2
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
6
7 package lastmodified2;
8
9 use strict;
10
11 use HTTP::Date;
12 use Data::Dumper;
13 use POSIX qw! strftime !;
14
15 # Use the Digest:MD5 module if available, the older MD5 module if not.
16
17 my $use_digest;
18 my $use_just_md5;
19
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     }
29 }
30
31 # --- Package variables -----
32
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;
38
39 # --- Output variables -----
40
41 our $latest_rfc822 = '';
42 our $latest_iso8601 = '';
43
44 our $others_rfc822 = '';
45 our $others_iso8601 = '';
46
47 our $now_rfc822 = '';
48 our $now_iso8601 = '';
49
50 our $story_rfc822 = '';
51 our $story_iso8601 = '';
52
53 # --- Configurable variables -----
54
55 my $generate_etag = 1;                  # generate ETag header?
56
57 my $generate_mod = 1;                   # generate Last-modified header?
58
59 my $strong = 0;                         # do strong validation?
60
61 my $val_cache = "validator.cache";      # where to cache last-modified values
62                                         # and MD5 digests (in state directory)
63
64 my $generate_expires = 0;               # generate Expires header?
65
66 my $generate_cache = 0;                 # generate Cache-control header?
67
68 my $freshness_time = 3000;              # number of seconds pages are fresh
69                                         # (0 = do not cache, max is 1 year)
70
71 my $generate_length = 1;                # generate Content-length header?
72
73 my $use_others = 0;                     # consult %others for weak validation
74                                         # (DEPRECATED)
75
76 my $export_dates = 1;                   # set $latest_rfc822, etc., for
77                                         # compatibility with lastmodified
78
79 my $debug = 0;                          # set > 0 for debug output
80
81 # --------------------------------
82
83
84 # Do any initial processing, and decide whether to activate the plugin.
85
86 sub start {
87     warn "lastmodified2: start\n" if $debug > 1;
88
89     # Don't activate this plugin if we are doing static page generation.
90
91     return 0 if $blosxom::static_or_dynamic eq 'static';
92
93     # If we can't do MD5 then we don't do strong validation.
94
95     if ($strong && !($use_digest || $use_just_md5)) {
96         $strong = 0;
97
98         warn "lastmodified2: MD5 not available, forcing weak validation\n"
99             if $debug > 0;
100     }
101
102     # Limit freshness time to maximum of one year, must be non-negative.
103
104     $freshness_time > 365*24*3600 and $freshness_time = 365*24*3600;
105     $freshness_time < 0 and $freshness_time = 0;
106
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     }
116
117     # If we are using Last-modified as a strong validator then read
118     # in the cached last-modified values and MD5 digests.
119
120     if ($generate_mod && $strong &&
121         open CACHE, "<$blosxom::plugin_state_dir/$val_cache" ) {
122
123         warn "lastmodified2: loading cached validators\n" if $debug > 0;
124
125         my $index = join '', <CACHE>;
126         close CACHE;
127
128         my $VAR1;
129         $index =~ m!\$VAR1 = \{!
130             and eval($index) and !$@ and %validator = %$VAR1;
131     }
132
133     # Convert current time to RFC 822 and ISO 8601 formats for others' use.
134
135     if ($export_dates && $current_time) {
136         $now_rfc822 = HTTP::Date::time2str($current_time);
137         $now_iso8601 = iso8601($current_time);
138     }
139
140     return 1;
141 }
142
143
144 # We check the list of entries to be displayed and determine the modification
145 # time of the most recent entry.
146
147 sub filter {
148     my ($pkg, $files, $others) = @_;
149
150     warn "lastmodified2: filter\n" if $debug > 1;
151
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.
154
155     return 1 unless $export_dates ||
156         (($generate_etag || $generate_mod) && !$strong);
157
158     # Find the latest date/time modified for the entries to be displayed.
159
160     $last_modified_time = 0;
161     for (values %$files) {
162         $_ > $last_modified_time and $last_modified_time = $_;
163     }
164
165     warn "lastmodified2: \$last_modified_time = " .
166         $last_modified_time . " (entries)\n" if $debug > 0;
167
168     # Convert last modified time to RFC 822 and ISO 8601 formats for others.
169
170     if ($export_dates && $last_modified_time) {
171         $latest_rfc822 = HTTP::Date::time2str($last_modified_time);
172         $latest_iso8601 = iso8601($last_modified_time);
173     }
174
175     # Optionally look at other files as well (DEPRECATED).
176
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         }
183
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         }
188
189         warn "lastmodified2: \$others_last_modified_time = " .
190             $others_last_modified_time . " (others)\n" if $debug > 0;
191
192         $others_last_modified_time > $last_modified_time
193             and $last_modified_time = $others_last_modified_time;
194     }
195
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/').
198
199     if ($generate_etag && !$strong) {
200         $etag = 'W/"' . $last_modified_time . '"';
201
202         warn "lastmodified2: \$etag = $etag\n" if $debug > 0;
203     }
204
205     return 1;
206 }
207
208
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.
211
212 sub skip {
213     warn "lastmodified2: skip\n" if $debug > 1;
214
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.
218
219     return 0 unless ($generate_etag || $generate_mod) && !$strong;
220
221     # Otherwise we can check here whether we can send a 304 or not.
222
223     my $send_304 = check_for_304();
224
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.)
229
230     add_headers($send_304) if $send_304;
231
232     return $send_304;
233 }
234
235
236 # Set variables with story date/time in RFC 822 and ISO 8601 formats.
237
238 sub story {
239     my ($pkg, $path, $filename, $story_ref, $title_ref, $body_ref) = @_;
240
241     warn "lastmodified2: story (\$path = $path, \$filename = $filename)\n"
242         if $debug > 1;
243
244     if ($export_dates) {
245         $path ||= "";
246
247         my $timestamp =
248             $blosxom::files{"$blosxom::datadir$path/$filename.$blosxom::file_extension"};
249
250         warn "lastmodified2: \$timestamp = $timestamp\n" if $debug > 0;
251
252         $story_rfc822 = $timestamp ? HTTP::Date::time2str($timestamp) : '';
253         $story_iso8601 = $timestamp ? iso8601($timestamp) : '';
254     }
255
256     return 1;
257 }
258
259
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.
263
264 sub last {
265     warn "lastmodified2: last\n" if $debug > 1;
266
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.
269
270     return 1 if $blosxom::header->{'Status'} &&
271         $blosxom::header->{'Status'} !~ m!^200 !;
272
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.)
277
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 . '"';
283
284         warn "lastmodified2: \$etag = $etag\n" if $debug > 0;
285     }
286
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.
291
292     my $cache_tag = cache_tag();
293     my $update_cache = 0;
294
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         }
305
306         warn "lastmodified2: \$last_modified_time = $last_modified_time\n"
307             if $debug > 0;
308
309     }
310
311     # Do conditional GET checks and output configured headers plus status.
312
313     my $send_304 = check_for_304();
314     add_headers($send_304);
315
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.
318
319     if ($update_cache) {
320         warn "lastmodified2: updating validator cache\n" if $debug > 0;
321
322         my $tmp_cache = "$val_cache-$$-$current_time";
323
324         if (open CACHE, ">$blosxom::plugin_state_dir/$tmp_cache") {
325             print CACHE Dumper \%validator;
326             close CACHE;
327
328             warn "lastmodified2: renaming $tmp_cache to $val_cache\n"
329                 if $debug > 1;
330
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     }
338
339     1;
340 }
341
342
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.
345
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;
352
353     warn "lastmodified2: check_for_304\n" if $debug > 1;
354
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.
359
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'};
364
365             if ($debug > 0) {
366                 for (@inm_etags) {
367                     warn "lastmodified2: \$inm_etag = |" . $_ . "|\n";
368                 }
369             }
370
371             for (@inm_etags) {
372                 $etag eq $_ and $etag_send_304 = 1 and last;
373             }
374         }
375     }
376
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.
381
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'});
387
388             warn "lastmodified2: \$ims_time = " . $ims_time . "\n"
389                 if $debug > 0;
390
391             $mod_send_304 = 1 if $last_modified_time <= $ims_time;
392         }
393     }
394
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.
398
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     }
404
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;
409
410     return $send_304;
411 }
412
413
414 # Set status and add additional header(s) depending on the type of response.
415
416 sub add_headers {
417     my ($send_304) = @_;
418
419     warn "lastmodified2: add_headers (\$send_304 = $send_304)\n"
420         if $debug > 1;
421
422     # Set HTTP status and truncate output if we are sending a 304 response.
423
424     if ($send_304) {
425         $blosxom::header->{'Status'} = "304 Not Modified";
426         $blosxom::output = "";
427
428         warn "lastmodified2: Status: " .
429             $blosxom::header->{'Status'} . "\n" if $debug > 0;
430     }
431
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.
434
435     # Last-modified is not returned on a 304 response.
436
437     if ($generate_mod && !$send_304) {
438         $blosxom::header->{'Last-modified'} =
439             HTTP::Date::time2str($last_modified_time);
440
441         warn "lastmodified2: Last-modified: " .
442             $blosxom::header->{'Last-modified'} . "\n" if $debug > 0;
443     }
444
445     # If we send ETag on a 200 response then we send it on a 304 as well.
446
447     if ($generate_etag) {
448         $blosxom::header->{'ETag'} = $etag;
449
450         warn "lastmodified2: ETag: " .
451             $blosxom::header->{'ETag'} . "\n" if $debug > 0;
452     }
453
454     # We send Expires for a 304 since its value is updated for each request.
455
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);
460
461         warn "lastmodified2: Expires: " .
462             $blosxom::header->{'Expires'} . "\n" if $debug > 0;
463     }
464
465     # We send Cache-control for a 304 response for consistency with Expires.
466
467     if ($generate_cache) {
468         $blosxom::header->{'Cache-control'} =
469             $freshness_time ? "max-age=" . $freshness_time
470                             : "no-cache";
471
472         warn "lastmodified2: Cache-control: " .
473             $blosxom::header->{'Cache-control'} . "\n" if $debug > 0;
474     }
475
476     # Content-length is not returned on a 304 response.
477
478     if ($generate_length && !$send_304) {
479         $blosxom::header->{'Content-length'} = length($blosxom::output);
480
481         warn "lastmodified2: Content-length: " .
482             $blosxom::header->{'Content-length'} . "\n" if $debug > 0;
483     }
484 }
485
486
487 # Generate a tag to look up the cached last-modified value and MD5 digest
488 # for this URI.
489
490 sub cache_tag {
491     # Start with the original URI from the request.
492
493     my $tag = $ENV{REQUEST_URI} || "";
494
495     # Add an "/index.flavour" for uniqueness unless it's already present.
496
497     unless ($tag =~ m!/index\.!) {
498         $tag .= '/' unless ($tag =~ m!/$!);
499         $tag .= "index.$blosxom::flavour";
500     }
501
502     return $tag;
503 }
504
505
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)
508
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;
514 }
515
516
517 1;
518
519 __END__
520
521 =head1 NAME
522
523 Blosxom Plug-in: lastmodified2
524
525 =head1 SYNOPSIS
526
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.
533
534 =head1 VERSION
535
536 0.10
537
538 =head1 AUTHOR
539
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/)
542
543 =head1 DESCRIPTION
544
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.
552
553 =head1 INSTALLATION AND CONFIGURATION
554
555 Copy this plugin into your Blosxom plugin directory. You should not
556 normally need to rename the plugin; however see the discussion below.
557
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.
567
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.
571
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.
585
586 =head1 SEE ALSO
587
588 Blosxom Home/Docs/Licensing: http://blosxom.sourceforge.net/
589
590 Blosxom Plugin Docs: http://blosxom.sourceforge.net/documentation/users/plugins.html
591
592 lastmodified plugin: http://www.cobblers.net/blog/dev/blosxom/
593
594 more on the lastmodified2 plugin: http://www.hecker.org/blosxom/lastmodified2
595
596 =head1 BUGS
597
598 None known; please send bug reports and feedback to the Blosxom
599 development mailing list <blosxom-devel@lists.sourceforge.net>.
600
601 =head1 AUTHOR
602
603 Frank Hecker <hecker@hecker.org> http://www.hecker.org/
604
605 Based on the original lastmodified plugin by Bob Schumaker
606 <cobblers@pobox.com> http://www.cobblers.net/blog
607
608 This plugin is now maintained by the Blosxom Sourceforge Team,
609 <blosxom-devel@lists.sourceforge.net>.
610
611 =head1 LICENSE
612
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.
617
618 THIS SOFTWARE IS PROVIDED AS IS AND WITHOUT ANY WARRANTY OF ANY KIND.
619 USE AT YOUR OWN RISK!