fix tests for clear_flash (mocking was bad)
[catagits/Catalyst-Plugin-Session.git] / lib / Catalyst / Plugin / Session.pm
1 #!/usr/bin/perl
2
3 package Catalyst::Plugin::Session;
4 use base qw/Class::Accessor::Fast/;
5
6 use strict;
7 use warnings;
8
9 use NEXT;
10 use Catalyst::Exception ();
11 use Digest              ();
12 use overload            ();
13 use Object::Signature   ();
14
15 our $VERSION = "0.13";
16
17 my @session_data_accessors; # used in delete_session
18 BEGIN {
19     __PACKAGE__->mk_accessors(
20         "_session_delete_reason",
21         @session_data_accessors = qw/
22           _sessionid
23           _session
24           _session_expires
25           _extended_session_expires
26           _session_data_sig
27           _flash
28           _flash_keep_keys
29           _flash_key_hashes
30           _tried_loading_session_id
31           _tried_loading_session_data
32           _tried_loading_session_expires
33           _tried_loading_flash_data
34           /
35     );
36 }
37
38 sub setup {
39     my $c = shift;
40
41     $c->NEXT::setup(@_);
42
43     $c->check_session_plugin_requirements;
44     $c->setup_session;
45
46     return $c;
47 }
48
49 sub check_session_plugin_requirements {
50     my $c = shift;
51
52     unless ( $c->isa("Catalyst::Plugin::Session::State")
53         && $c->isa("Catalyst::Plugin::Session::Store") )
54     {
55         my $err =
56           (     "The Session plugin requires both Session::State "
57               . "and Session::Store plugins to be used as well." );
58
59         $c->log->fatal($err);
60         Catalyst::Exception->throw($err);
61     }
62 }
63
64 sub setup_session {
65     my $c = shift;
66
67     my $cfg = ( $c->config->{session} ||= {} );
68
69     %$cfg = (
70         expires        => 7200,
71         verify_address => 1,
72         %$cfg,
73     );
74
75     $c->NEXT::setup_session();
76 }
77
78 sub prepare_action {
79     my $c = shift;
80
81     if (    $c->config->{session}{flash_to_stash}
82         and $c->sessionid
83         and my $flash_data = $c->flash )
84     {
85         @{ $c->stash }{ keys %$flash_data } = values %$flash_data;
86     }
87
88     $c->NEXT::prepare_action(@_);
89 }
90
91 sub finalize {
92     my $c = shift;
93
94     $c->finalize_session;
95     
96     $c->NEXT::finalize(@_);
97 }
98
99 sub finalize_session {
100     my $c = shift;
101
102     $c->NEXT::finalize_session;
103
104     $c->_save_session_id;
105     $c->_save_session;
106     $c->_save_flash;
107     $c->_save_session_expires;
108
109     $c->_clear_session_instance_data;
110 }
111
112 sub _save_session_id {
113     my $c = shift;
114
115     # we already called set when allocating
116     # no need to tell the state plugins anything new
117 }
118
119 sub _save_session_expires {
120     my $c = shift;
121
122     if ( defined($c->_session_expires) ) {
123         my $expires = $c->session_expires; # force extension
124
125         my $sid = $c->sessionid;
126         $c->store_session_data( "expires:$sid" => $expires );
127     }
128 }
129
130 sub _save_session {
131     my $c = shift;
132
133     if ( my $session_data = $c->_session ) {
134
135         no warnings 'uninitialized';
136         if ( Object::Signature::signature($session_data) ne
137             $c->_session_data_sig )
138         {
139             $session_data->{__updated} = time();
140             my $sid = $c->sessionid;
141             $c->store_session_data( "session:$sid" => $session_data );
142         }
143     }
144 }
145
146 sub _save_flash {
147     my $c = shift;
148
149     if ( my $flash_data = $c->_flash ) {
150
151         my $hashes = $c->_flash_key_hashes || {};
152         my $keep = $c->_flash_keep_keys || {};
153         foreach my $key ( keys %$hashes ) {
154             if ( !exists $keep->{$key} and Object::Signature::signature( \$flash_data->{$key} ) eq $hashes->{$key} ) {
155                 delete $flash_data->{$key};
156             }
157         }
158         
159         my $sid = $c->sessionid;
160
161         if (%$flash_data) {
162             $c->store_session_data( "flash:$sid", $flash_data );
163         }
164         else {
165             $c->delete_session_data("flash:$sid");
166         }
167     }
168 }
169
170 sub _load_session_expires {
171     my $c = shift;
172     return $c->_session_expires if $c->_tried_loading_session_expires;
173     $c->_tried_loading_session_expires(1);
174
175     if ( my $sid = $c->sessionid ) {
176         my $expires = $c->get_session_data("expires:$sid") || 0;
177
178         if ( $expires >= time() ) {
179             $c->_session_expires( $expires );
180             return $expires;
181         } else {
182             $c->delete_session( "session expired" );
183             return 0;
184         }
185     }
186
187     return;
188 }
189
190 sub _load_session {
191     my $c = shift;
192     return $c->_session if $c->_tried_loading_session_data;
193     $c->_tried_loading_session_data(1);
194
195     if ( my $sid = $c->sessionid ) {
196         if ( $c->_load_session_expires ) {    # > 0
197
198             my $session_data = $c->get_session_data("session:$sid") || return;
199             $c->_session($session_data);
200
201             no warnings 'uninitialized';    # ne __address
202             if (   $c->config->{session}{verify_address}
203                 && $session_data->{__address} ne $c->request->address )
204             {
205                 $c->log->warn(
206                         "Deleting session $sid due to address mismatch ("
207                       . $session_data->{__address} . " != "
208                       . $c->request->address . ")"
209                 );
210                 $c->delete_session("address mismatch");
211                 return;
212             }
213
214             $c->log->debug(qq/Restored session "$sid"/) if $c->debug;
215             $c->_session_data_sig( Object::Signature::signature($session_data) ) if $session_data;
216             $c->_expire_session_keys;
217
218             return $session_data;
219         }
220     }
221
222     return;
223 }
224
225 sub _load_flash {
226     my $c = shift;
227     return $c->_flash if $c->_tried_loading_flash_data;
228     $c->_tried_loading_flash_data(1);
229
230     if ( my $sid = $c->sessionid ) {
231         if ( my $flash_data = $c->_flash
232             || $c->_flash( $c->get_session_data("flash:$sid") ) )
233         {
234             $c->_flash_key_hashes({ map { $_ => Object::Signature::signature( \$flash_data->{$_} ) } keys %$flash_data });
235             
236             return $flash_data;
237         }
238     }
239
240     return;
241 }
242
243 sub _expire_session_keys {
244     my ( $c, $data ) = @_;
245
246     my $now = time;
247
248     my $expire_times = ( $data || $c->_session || {} )->{__expire_keys} || {};
249     foreach my $key ( grep { $expire_times->{$_} < $now } keys %$expire_times ) {
250         delete $c->_session->{$key};
251         delete $expire_times->{$key};
252     }
253 }
254
255 sub _clear_session_instance_data {
256     my $c = shift;
257     $c->$_(undef) for @session_data_accessors;
258     $c->NEXT::_clear_session_instance_data; # allow other plugins to hook in on this
259 }
260
261 sub delete_session {
262     my ( $c, $msg ) = @_;
263
264     $c->log->debug("Deleting session" . ( defined($msg) ? "($msg)" : '(no reason given)') ) if $c->debug;
265
266     # delete the session data
267     if ( my $sid = $c->sessionid ) {
268         $c->delete_session_data("${_}:${sid}") for qw/session expires flash/;
269         $c->delete_session_id($sid);
270     }
271
272     # reset the values in the context object
273     # see the BEGIN block
274     $c->_clear_session_instance_data;
275
276     $c->_session_delete_reason($msg);
277 }
278
279 sub session_delete_reason {
280     my $c = shift;
281
282     $c->session_is_valid; # check that it was loaded
283
284     $c->_session_delete_reason(@_);
285 }
286
287 sub session_expires {
288     my $c = shift;
289
290     if ( defined( my $expires = $c->_extended_session_expires ) ) {
291         return $expires;
292     } elsif ( defined( $expires = $c->_load_session_expires ) ) {
293         return $c->extend_session_expires( $expires );
294     } else {
295         return 0;
296     }
297 }
298
299 sub extend_session_expires {
300     my ( $c, $expires ) = @_;
301     $c->_extended_session_expires( my $updated = $c->calculate_extended_session_expires( $expires ) );
302     $c->extend_session_id( $c->sessionid, $updated );
303     return $updated;
304 }
305
306 sub calculate_initial_session_expires {
307     my $c = shift;
308     return ( time() + $c->config->{session}{expires} );
309 }
310
311 sub calculate_extended_session_expires {
312     my ( $c, $prev ) = @_;
313     $c->calculate_initial_session_expires;
314 }
315
316 sub reset_session_expires {
317     my ( $c, $sid ) = @_;
318     
319     my $exp = $c->calculate_initial_session_expires;
320     $c->_session_expires( $exp );
321     $c->_extended_session_expires( $exp );
322     $exp;
323 }
324
325 sub sessionid {
326     my $c = shift;
327     
328     return $c->_sessionid || $c->_load_sessionid;
329 }
330
331 sub _load_sessionid {
332     my $c = shift;
333     return if $c->_tried_loading_session_id;
334     $c->_tried_loading_session_id(1);
335
336     if ( defined( my $sid = $c->get_session_id ) ) {
337         if ( $c->validate_session_id($sid) ) {
338             # temporarily set the inner key, so that validation will work
339             $c->_sessionid($sid);
340             return $sid;
341         } else {
342             my $err = "Tried to set invalid session ID '$sid'";
343             $c->log->error($err);
344             Catalyst::Exception->throw($err);
345         }
346     }
347
348     return;
349 }
350
351 sub session_is_valid {
352     my $c = shift;
353
354     # force a check for expiry, but also __address, etc
355     if ( $c->_load_session ) {
356         return 1;
357     } else {
358         return;
359     }
360 }
361
362 sub validate_session_id {
363     my ( $c, $sid ) = @_;
364
365     $sid and $sid =~ /^[a-f\d]+$/i;
366 }
367
368 sub session {
369     my $c = shift;
370
371     $c->_session || $c->_load_session || do {
372         $c->create_session_id_if_needed;
373         $c->initialize_session_data;
374     };
375 }
376
377 sub keep_flash {
378     my ( $c, @keys ) = @_;
379     my $href = $c->_flash_keep_keys || $c->_flash_keep_keys({});
380     (@{$href}{@keys}) = ((undef) x @keys);
381 }
382
383 sub flash {
384     my $c = shift;
385     $c->_flash || $c->_load_flash || do {
386         $c->create_session_id_if_needed;
387         $c->_flash( {} );
388     }
389 }
390
391 sub clear_flash {
392     my $c = shift;
393     
394     #$c->delete_session_data("flash:" . $c->sessionid); # should this be in here? or delayed till finalization?
395     $c->_flash_key_hashes({});
396     $c->_flash_keep_keys({});
397     $c->_flash({});
398 }
399
400 sub session_expire_key {
401     my ( $c, %keys ) = @_;
402
403     my $now = time;
404     @{ $c->session->{__expire_keys} }{ keys %keys } =
405       map { $now + $_ } values %keys;
406 }
407
408 sub initialize_session_data {
409     my $c = shift;
410
411     my $now = time;
412
413     return $c->_session(
414         {
415             __created => $now,
416             __updated => $now,
417
418             (
419                 $c->config->{session}{verify_address}
420                 ? ( __address => $c->request->address )
421                 : ()
422             ),
423         }
424     );
425 }
426
427 sub generate_session_id {
428     my $c = shift;
429
430     my $digest = $c->_find_digest();
431     $digest->add( $c->session_hash_seed() );
432     return $digest->hexdigest;
433 }
434
435 sub create_session_id_if_needed {
436     my $c = shift;
437     $c->create_session_id unless $c->sessionid;
438 }
439
440 sub create_session_id {
441     my $c = shift;
442     
443     my $sid = $c->generate_session_id;
444
445     $c->log->debug(qq/Created session "$sid"/) if $c->debug;
446
447     $c->_sessionid($sid);
448     $c->reset_session_expires;
449     $c->set_session_id($sid);
450
451     return $sid;
452 }
453
454 my $counter;
455
456 sub session_hash_seed {
457     my $c = shift;
458
459     return join( "", ++$counter, time, rand, $$, {}, overload::StrVal($c), );
460 }
461
462 my $usable;
463
464 sub _find_digest () {
465     unless ($usable) {
466         foreach my $alg (qw/SHA-1 SHA-256 MD5/) {
467             if ( eval { Digest->new($alg) } ) {
468                 $usable = $alg;
469                 last;
470             }
471         }
472         Catalyst::Exception->throw(
473                 "Could not find a suitable Digest module. Please install "
474               . "Digest::SHA1, Digest::SHA, or Digest::MD5" )
475           unless $usable;
476     }
477
478     return Digest->new($usable);
479 }
480
481 sub dump_these {
482     my $c = shift;
483
484     (
485         $c->NEXT::dump_these(),
486
487         $c->sessionid
488         ? ( [ "Session ID" => $c->sessionid ], [ Session => $c->session ], )
489         : ()
490     );
491 }
492
493
494 sub get_session_id { shift->NEXT::get_session_id(@_) }
495 sub set_session_id { shift->NEXT::set_session_id(@_) }
496 sub delete_session_id { shift->NEXT::delete_session_id(@_) }
497 sub extend_session_id { shift->NEXT::extend_session_id(@_) }
498
499 __PACKAGE__;
500
501 __END__
502
503 =pod
504
505 =head1 NAME
506
507 Catalyst::Plugin::Session - Generic Session plugin - ties together server side
508 storage and client side state required to maintain session data.
509
510 =head1 SYNOPSIS
511
512     # To get sessions to "just work", all you need to do is use these plugins:
513
514     use Catalyst qw/
515       Session
516       Session::Store::FastMmap
517       Session::State::Cookie
518       /;
519
520         # you can replace Store::FastMmap with Store::File - both have sensible
521         # default configurations (see their docs for details)
522
523         # more complicated backends are available for other scenarios (DBI storage,
524         # etc)
525
526
527     # after you've loaded the plugins you can save session data
528     # For example, if you are writing a shopping cart, it could be implemented
529     # like this:
530
531     sub add_item : Local {
532         my ( $self, $c ) = @_;
533
534         my $item_id = $c->req->param("item");
535
536         # $c->session is a hash ref, a bit like $c->stash
537         # the difference is that it' preserved across requests
538
539         push @{ $c->session->{items} }, $item_id;
540
541         $c->forward("MyView");
542     }
543
544     sub display_items : Local {
545         my ( $self, $c ) = @_;
546
547         # values in $c->session are restored
548         $c->stash->{items_to_display} =
549           [ map { MyModel->retrieve($_) } @{ $c->session->{items} } ];
550
551         $c->forward("MyView");
552     }
553
554 =head1 DESCRIPTION
555
556 The Session plugin is the base of two related parts of functionality required
557 for session management in web applications.
558
559 The first part, the State, is getting the browser to repeat back a session key,
560 so that the web application can identify the client and logically string
561 several requests together into a session.
562
563 The second part, the Store, deals with the actual storage of information about
564 the client. This data is stored so that the it may be revived for every request
565 made by the same client.
566
567 This plugin links the two pieces together.
568
569 =head1 RECCOMENDED BACKENDS
570
571 =over 4
572
573 =item Session::State::Cookie
574
575 The only really sane way to do state is using cookies.
576
577 =item Session::Store::File
578
579 A portable backend, based on Cache::File.
580
581 =item Session::Store::FastMmap
582
583 A fast and flexible backend, based on Cache::FastMmap.
584
585 =back
586
587 =head1 METHODS
588
589 =over 4
590
591 =item sessionid
592
593 An accessor for the session ID value.
594
595 =item session
596
597 Returns a hash reference that might contain unserialized values from previous
598 requests in the same session, and whose modified value will be saved for future
599 requests.
600
601 This method will automatically create a new session and session ID if none
602 exists.
603
604 =item session_expires
605
606 =item session_expires $reset
607
608 This method returns the time when the current session will expire, or 0 if
609 there is no current session. If there is a session and it already expired, it
610 will delete the session and return 0 as well.
611
612 If the C<$reset> parameter is true, and there is a session ID the expiry time
613 will be reset to the current time plus the time to live (see
614 L</CONFIGURATION>). This is used when creating a new session.
615
616 =item flash
617
618 This is like Ruby on Rails' flash data structure. Think of it as a stash that
619 lasts for longer than one request, letting you redirect instead of forward.
620
621 The flash data will be cleaned up only on requests on which actually use
622 $c->flash (thus allowing multiple redirections), and the policy is to delete
623 all the keys which haven't changed since the flash data was loaded at the end
624 of every request.
625
626     sub moose : Local {
627         my ( $self, $c ) = @_;
628
629         $c->flash->{beans} = 10;
630         $c->response->redirect( $c->uri_for("foo") );
631     }
632
633     sub foo : Local {
634         my ( $self, $c ) = @_;
635
636         my $value = $c->flash->{beans};
637
638         # ...
639
640         $c->response->redirect( $c->uri_for("bar") );
641     }
642
643     sub bar : Local {
644         my ( $self, $c ) = @_;
645
646         if ( exists $c->flash->{beans} ) { # false
647         
648         }
649     }
650
651 =item clear_flash
652
653 Zap all the keys in the flash regardless of their current state.
654
655 =item keep_flash @keys
656
657 If you wawnt to keep a flash key for the next request too, even if it hasn't
658 changed, call C<keep_flash> and pass in the keys as arguments.
659
660 =item delete_session REASON
661
662 This method is used to invalidate a session. It takes an optional parameter
663 which will be saved in C<session_delete_reason> if provided.
664
665 =item session_delete_reason
666
667 This accessor contains a string with the reason a session was deleted. Possible
668 values include:
669
670 =over 4
671
672 =item *
673
674 C<address mismatch>
675
676 =item *
677
678 C<session expired>
679
680 =back
681
682 =item session_expire_key $key, $ttl
683
684 Mark a key to expire at a certain time (only useful when shorter than the
685 expiry time for the whole session).
686
687 For example:
688
689     __PACKAGE__->config->{session}{expires} = 1000000000000; # forever
690
691     # later
692
693     $c->session_expire_key( __user => 3600 );
694
695 Will make the session data survive, but the user will still be logged out after
696 an hour.
697
698 Note that these values are not auto extended.
699
700 =back
701
702 =head1 INTERNAL METHODS
703
704 =over 4
705
706 =item setup
707
708 This method is extended to also make calls to
709 C<check_session_plugin_requirements> and C<setup_session>.
710
711 =item check_session_plugin_requirements
712
713 This method ensures that a State and a Store plugin are also in use by the
714 application.
715
716 =item setup_session
717
718 This method populates C<< $c->config->{session} >> with the default values
719 listed in L</CONFIGURATION>.
720
721 =item prepare_action
722
723 This methoid is extended.
724
725 It's only effect is if the (off by default) C<flash_to_stash> configuration
726 parameter is on - then it will copy the contents of the flash to the stash at
727 prepare time.
728
729 =item finalize
730
731 This method is extended and will extend the expiry time, as well as persist the
732 session data if a session exists.
733
734 =item initialize_session_data
735
736 This method will initialize the internal structure of the session, and is
737 called by the C<session> method if appropriate.
738
739 =item create_session_id
740
741 Creates a new session id using C<generate_session_id> if there is no session ID
742 yet.
743
744 =item validate_session_id SID
745
746 Make sure a session ID is of the right format.
747
748 This currently ensures that the session ID string is any amount of case
749 insensitive hexadecimal characters.
750
751 =item generate_session_id
752
753 This method will return a string that can be used as a session ID. It is
754 supposed to be a reasonably random string with enough bits to prevent
755 collision. It basically takes C<session_hash_seed> and hashes it using SHA-1,
756 MD5 or SHA-256, depending on the availibility of these modules.
757
758 =item session_hash_seed
759
760 This method is actually rather internal to generate_session_id, but should be
761 overridable in case you want to provide more random data.
762
763 Currently it returns a concatenated string which contains:
764
765 =over 4
766
767 =item *
768
769 A counter
770
771 =item *
772
773 The current time
774
775 =item *
776
777 One value from C<rand>.
778
779 =item *
780
781 The stringified value of a newly allocated hash reference
782
783 =item *
784
785 The stringified value of the Catalyst context object
786
787 =back
788
789 In the hopes that those combined values are entropic enough for most uses. If
790 this is not the case you can replace C<session_hash_seed> with e.g.
791
792     sub session_hash_seed {
793         open my $fh, "<", "/dev/random";
794         read $fh, my $bytes, 20;
795         close $fh;
796         return $bytes;
797     }
798
799 Or even more directly, replace C<generate_session_id>:
800
801     sub generate_session_id {
802         open my $fh, "<", "/dev/random";
803         read $fh, my $bytes, 20;
804         close $fh;
805         return unpack("H*", $bytes);
806     }
807
808 Also have a look at L<Crypt::Random> and the various openssl bindings - these
809 modules provide APIs for cryptographically secure random data.
810
811 =item finalize_session
812
813 Clean up the session during C<finalize>.
814
815 This clears the various accessors after saving to the store.
816
817 =item dump_these
818
819 See L<Catalyst/dump_these> - ammends the session data structure to the list of
820 dumped objects if session ID is defined.
821
822 =back
823
824 =head1 USING SESSIONS DURING PREPARE
825
826 The earliest point in time at which you may use the session data is after
827 L<Catalyst::Plugin::Session>'s C<prepare_action> has finished.
828
829 State plugins must set $c->session ID before C<prepare_action>, and during
830 C<prepare_action> L<Catalyst::Plugin::Session> will actually load the data from
831 the store.
832
833         sub prepare_action {
834                 my $c = shift;
835
836                 # don't touch $c->session yet!
837
838                 $c->NEXT::prepare_action( @_ );
839
840                 $c->session;  # this is OK
841                 $c->sessionid; # this is also OK
842         }
843
844 =head1 CONFIGURATION
845
846     $c->config->{session} = {
847         expires => 1234,
848     };
849
850 All configuation parameters are provided in a hash reference under the
851 C<session> key in the configuration hash.
852
853 =over 4
854
855 =item expires
856
857 The time-to-live of each session, expressed in seconds. Defaults to 7200 (two
858 hours).
859
860 =item verify_address
861
862 When true, C<<$c->request->address>> will be checked at prepare time. If it is
863 not the same as the address that initiated the session, the session is deleted.
864
865 =item flash_to_stash
866
867 This option makes it easier to have actions behave the same whether they were
868 forwarded to or redirected to. On prepare time it copies the contents of
869 C<flash> (if any) to the stash.
870
871 =back
872
873 =head1 SPECIAL KEYS
874
875 The hash reference returned by C<< $c->session >> contains several keys which
876 are automatically set:
877
878 =over 4
879
880 =item __expires
881
882 This key no longer exists. Use C<session_expires> instead.
883
884 =item __updated
885
886 The last time a session was saved to the store.
887
888 =item __created
889
890 The time when the session was first created.
891
892 =item __address
893
894 The value of C<< $c->request->address >> at the time the session was created.
895 This value is only populated if C<verify_address> is true in the configuration.
896
897 =back
898
899 =head1 CAVEATS
900
901 =head2 Round the Robin Proxies
902
903 C<verify_address> could make your site inaccessible to users who are behind
904 load balanced proxies. Some ISPs may give a different IP to each request by the
905 same client due to this type of proxying. If addresses are verified these
906 users' sessions cannot persist.
907
908 To let these users access your site you can either disable address verification
909 as a whole, or provide a checkbox in the login dialog that tells the server
910 that it's OK for the address of the client to change. When the server sees that
911 this box is checked it should delete the C<__address> sepcial key from the
912 session hash when the hash is first created.
913
914 =head2 Race Conditions
915
916 In this day and age where cleaning detergents and dutch football (not the
917 american kind) teams roam the plains in great numbers, requests may happen
918 simultaneously. This means that there is some risk of session data being
919 overwritten, like this:
920
921 =over 4
922
923 =item 1.
924
925 request a starts, request b starts, with the same session id
926
927 =item 2.
928
929 session data is loaded in request a
930
931 =item 3.
932
933 session data is loaded in request b
934
935 =item 4.
936
937 session data is changed in request a
938
939 =item 5.
940
941 request a finishes, session data is updated and written to store
942
943 =item 6.
944
945 request b finishes, session data is updated and written to store, overwriting
946 changes by request a
947
948 =back
949
950 If this is a concern in your application, a soon to be developed locking
951 solution is the only safe way to go. This will have a bigger overhead.
952
953 For applications where any given user is only making one request at a time this
954 plugin should be safe enough.
955
956 =head1 AUTHORS
957
958 =over 4
959
960 =item Andy Grundman
961
962 =item Christian Hansen
963
964 =item Yuval Kogman, C<nothingmuch@woobling.org> (current maintainer)
965
966 =item Sebastian Riedel
967
968 =back
969
970 And countless other contributers from #catalyst. Thanks guys!
971
972 =head1 COPYRIGHT & LICENSE
973
974         Copyright (c) 2005 the aforementioned authors. All rights
975         reserved. This program is free software; you can redistribute
976         it and/or modify it under the same terms as Perl itself.
977
978 =cut
979
980