big signup server cleanups. uses Storable for network protocol now.
authorivan <ivan>
Sat, 6 Apr 2002 20:37:38 +0000 (20:37 +0000)
committerivan <ivan>
Sat, 6 Apr 2002 20:37:38 +0000 (20:37 +0000)
- makes Bugs 384 & 385 easier
- closes: Bug#382

fs_signup/FS-SignupClient/Makefile.PL
fs_signup/FS-SignupClient/SignupClient.pm
fs_signup/FS-SignupClient/cgi/signup.cgi
fs_signup/FS-SignupClient/cgi/signup.html
fs_signup/FS-SignupClient/fs_signupd
fs_signup/FS-SignupClient/test.pl
fs_signup/fs_signup_server
httemplate/docs/install.html
httemplate/docs/signup.html

index 859d757..208e850 100644 (file)
@@ -6,5 +6,6 @@ WriteMakefile(
     'VERSION_FROM'  => 'SignupClient.pm', # finds $VERSION
     'EXE_FILES'     => [ 'fs_signupd' ],
     'INSTALLSCRIPT' => '/usr/local/sbin',
+    'INSTALLSITEBIN' => '/usr/local/sbin',
     'PERM_RWX'      => '750',
 );
index bd917da..3933703 100644 (file)
@@ -6,6 +6,7 @@ use Exporter;
 use Socket;
 use FileHandle;
 use IO::Handle;
+use Storable qw(nstore_fd fd_retrieve);
 
 $VERSION = '0.02';
 
@@ -58,6 +59,7 @@ FS::SignupClient - Freeside signup client API
     'pkgpart'          => $pkgpart,
     'username'         => $username,
     '_password'        => $password,
+    'sec_phrase'       => $sec_phrase,
     'popnum'           => $popnum,
   } );
 
@@ -104,51 +106,12 @@ sub signup_info {
   print SOCK "signup_info\n";
   SOCK->flush;
 
-  chop ( my $n_cust_main_county = <SOCK> );
-  my @cust_main_county = map {
-    chop ( my $taxnum  = <SOCK> ); 
-    chop ( my $state   = <SOCK> ); 
-    chop ( my $county  = <SOCK> ); 
-    chop ( my $country = <SOCK> );
-    {
-      'taxnum'  => $taxnum,
-      'state'   => $state,
-      'county'  => $county,
-      'country' => $country,
-    };
-  } 1 .. $n_cust_main_county;
-
-  chop ( my $n_part_pkg = <SOCK> );
-  my @part_pkg = map {
-    chop ( my $pkgpart = <SOCK> ); 
-    chop ( my $pkg     = <SOCK> ); 
-    {
-      'pkgpart' => $pkgpart,
-      'pkg'     => $pkg,
-    };
-  } 1 .. $n_part_pkg;
-
-  chop ( my $n_svc_acct_pop = <SOCK> );
-  my @svc_acct_pop = map {
-    chop ( my $popnum = <SOCK> ); 
-    chop ( my $city   = <SOCK> ); 
-    chop ( my $state  = <SOCK> ); 
-    chop ( my $ac     = <SOCK> );
-    chop ( my $exch   = <SOCK> );
-    chop ( my $loc    = <SOCK> );
-    {
-      'popnum' => $popnum,
-      'city'   => $city,
-      'state'  => $state,
-      'ac'     => $ac,
-      'exch'   => $exch,
-      'loc'    => $loc,
-    };
-  } 1 .. $n_svc_acct_pop;
-
+  my $init_data = fd_retrieve(\*SOCK);
   close SOCK;
 
-  \@cust_main_county, \@part_pkg, \@svc_acct_pop;
+  (map { $init_data->{$_} } qw( cust_main_county part_pkg svc_acct_pop ) ),
+  $init_data;
+
 }
 
 =item new_customer HASHREF
@@ -188,6 +151,8 @@ sub new_customer {
   my $hashref = shift;
 
   #things that aren't necessary in base class, but are for signup server
+#  return "Passwords don't match"
+#    if $hashref->{'_password'} ne $hashref->{'_password2'}
   return "Empty password" unless $hashref->{'_password'};
   return "No POP selected" unless $hashref->{'popnum'};
 
@@ -195,11 +160,14 @@ sub new_customer {
   connect(SOCK, sockaddr_un($fs_signupd_socket)) or die "connect: $!";
   print SOCK "new_customer\n";
 
-  print SOCK join("\n", map { $hashref->{$_} } qw(
+  my $signup_data = { map { $_ => $hashref->{$_} } qw(
     first last ss company address1 address2 city county state zip country
     daytime night fax payby payinfo paydate payname invoicing_list
     referral_custnum pkgpart username _password popnum
-  ) ), "\n";
+  ) };
+
+  #
+  nstore_fd($signup_data, \*SOCK) or die "can't send customer signup: $!";
   SOCK->flush;
 
   chop( my $error = <SOCK> );
index b236e6f..d44782f 100755 (executable)
@@ -1,13 +1,13 @@
 #!/usr/bin/perl -Tw
 #
-# $Id: signup.cgi,v 1.15 2001-09-27 21:37:57 ivan Exp $
+# $Id: signup.cgi,v 1.16 2002-04-06 20:37:38 ivan Exp $
 
 use strict;
 use vars qw( @payby $cgi $locales $packages $pops $error
              $last $first $ss $company $address1 $address2 $city $state $county
              $country $zip $daytime $night $fax $invoicing_list $payby $payinfo
              $paydate $payname $referral_custnum
-             $pkgpart $username $password $popnum
+             $pkgpart $username $password $password2 $popnum
              $ieak_file $ieak_template $cck_file $cck_template
              $signup_html $signup_template $success_html $success_template
              $ac $exch $loc
@@ -115,37 +115,50 @@ if ( defined $cgi->param('magic') ) {
       $invoicing_list = 'POST';
     }
 
-    $error = new_customer ( {
-      'last'             => $last             = $cgi->param('last'),
-      'first'            => $first            = $cgi->param('first'),
-      'ss'               => $ss               = $cgi->param('ss'),
-      'company'          => $company          = $cgi->param('company'),
-      'address1'         => $address1         = $cgi->param('address1'),
-      'address2'         => $address2         = $cgi->param('address2'),
-      'city'             => $city             = $cgi->param('city'),
-      'county'           => $county,
-      'state'            => $state,
-      'zip'              => $zip              = $cgi->param('zip'),
-      'country'          => $country,
-      'daytime'          => $daytime          = $cgi->param('daytime'),
-      'night'            => $night            = $cgi->param('night'),
-      'fax'              => $fax              = $cgi->param('fax'),
-      'payby'            => $payby,
-      'payinfo'          => $payinfo,
-      'paydate'          => $paydate,
-      'payname'          => $payname,
-      'invoicing_list'   => $invoicing_list,
-      'referral_custnum' => $referral_custnum = $cgi->param('ref'),
-      'pkgpart'          => $pkgpart          = $cgi->param('pkgpart'),
-      'username'         => $username         = $cgi->param('username'),
-      '_password'        => $password         = $cgi->param('_password'),
-      'popnum'           => $popnum           = $cgi->param('popnum'),
-    } );
+    $error = '';
+
+    if ( $cgi->param('_password') ne $cgi->param('_password2') ) {
+      $error = "Passwords don't match";
+      $password  = '';
+      $password2 = '';
+    } else {
+      $password2 = $cgi->param('_password2');
+
+      $error = new_customer ( {
+        'last'             => $last             = $cgi->param('last'),
+        'first'            => $first            = $cgi->param('first'),
+        'ss'               => $ss               = $cgi->param('ss'),
+        'company'          => $company          = $cgi->param('company'),
+        'address1'         => $address1         = $cgi->param('address1'),
+        'address2'         => $address2         = $cgi->param('address2'),
+        'city'             => $city             = $cgi->param('city'),
+        'county'           => $county,
+        'state'            => $state,
+        'zip'              => $zip              = $cgi->param('zip'),
+        'country'          => $country,
+        'daytime'          => $daytime          = $cgi->param('daytime'),
+        'night'            => $night            = $cgi->param('night'),
+        'fax'              => $fax              = $cgi->param('fax'),
+        'payby'            => $payby,
+        'payinfo'          => $payinfo,
+        'paydate'          => $paydate,
+        'payname'          => $payname,
+        'invoicing_list'   => $invoicing_list,
+        'referral_custnum' => $referral_custnum = $cgi->param('ref'),
+        'pkgpart'          => $pkgpart          = $cgi->param('pkgpart'),
+        'username'         => $username         = $cgi->param('username'),
+        '_password'        => $password         = $cgi->param('_password'),
+        'popnum'           => $popnum           = $cgi->param('popnum'),
+      } );
+
+    }
+    
     if ( $error ) {
       print_form();
     } else {
       print_okay();
     }
+
   } else {
     die "unrecognized magic: ". $cgi->param('magic');
   }
@@ -173,6 +186,7 @@ if ( defined $cgi->param('magic') ) {
   $pkgpart = '';
   $username = '';
   $password = '';
+  $password2 = '';
   $popnum = '';
   $referral_custnum = $cgi->param('ref') || '';
   print_form;
@@ -463,10 +477,15 @@ Contact Information
 </TR>
 <TR>
   <TD ALIGN="right">Password</TD>
-  <TD><INPUT TYPE="text" NAME="_password" VALUE="<%= $password %>">
+  <TD><INPUT TYPE="password" NAME="_password" VALUE="<%= $password %>">
   (blank to generate)</TD>
 </TR>
 <TR>
+  <TD ALIGN="right">Re-enter Password</TD>
+  <TD><INPUT TYPE="password" NAME="_password2" VALUE="<%= $password2 %>">
+  </TD>
+</TR>
+<TR>
   <TD ALIGN="right">Access number</TD>
   <TD><%= popselector($popnum) %></TD>
 </TR>
index 0f4742d..909d1fe 100755 (executable)
@@ -130,6 +130,11 @@ Contact Information
   (blank to generate)</TD>
 </TR>
 <TR>
+  <TD ALIGN="right">Re-enter Password</TD>
+  <TD><INPUT TYPE="password" NAME="_password2" VALUE="<%= $password2 %>">
+  </TD>
+</TR>
+<TR>
   <TD ALIGN="right">Access number</TD>
   <TD><%= popselector($popnum) %></TD>
 </TR>
index 8b3cdde..e764f32 100755 (executable)
@@ -3,14 +3,15 @@
 # fs_signupd
 #
 # This is run REMOTELY over ssh by fs_signup_server.
-#
 
 use strict;
 use Socket;
+use Storable qw(nstore_fd fd_retrieve);
+use IO::Handle;
 
 use vars qw( $Debug );
 
-$Debug = 0;
+$Debug = 1;
 
 my($fs_signupd_socket)="/usr/local/freeside/fs_signupd_socket";
 
@@ -23,50 +24,8 @@ $ENV{'BASH_ENV'} = '';
 
 $|=1;
 
-warn "[fs_signupd] Reading locales...\n" if $Debug;
-chomp( my $n_cust_main_county = <STDIN> );
-my @cust_main_county = map {
-  chomp( my $taxnum = <STDIN> );
-  chomp( my $state = <STDIN> );
-  chomp( my $county = <STDIN> );
-  chomp( my $country = <STDIN> );
-  {
-    'taxnum'  => $taxnum,
-    'state'   => $state,
-    'county'  => $county,
-    'country' => $country,
-  };
-} ( 1 .. $n_cust_main_county );
-
-warn "[fs_signupd] Reading package definitions...\n" if $Debug;
-chomp( my $n_part_pkg = <STDIN> );
-my @part_pkg = map {
-  chomp( my $pkgpart = <STDIN> );
-  chomp( my $pkg = <STDIN> );
-  {
-    'pkgpart' => $pkgpart,
-    'pkg'     => $pkg,
-  };
-} ( 1 .. $n_part_pkg );
-
-warn "[fs_signupd] Reading POPs...\n" if $Debug;
-chomp( my $n_svc_acct_pop = <STDIN> );
-my @svc_acct_pop = map {
-  chomp( my $popnum = <STDIN> );
-  chomp( my $city = <STDIN> );
-  chomp( my $state = <STDIN> );
-  chomp( my $ac = <STDIN> );
-  chomp( my $exch = <STDIN> );
-  chomp( my $loc = <STDIN> );
-  {
-    'popnum' => $popnum,
-    'city'   => $city,
-    'state'  => $state,
-    'ac'     => $ac,
-    'exch'   => $exch,
-    'loc'    => $loc,
-  };
-} ( 1 .. $n_svc_acct_pop );
+warn "[fs_signupd] Reading init data...\n" if $Debug;
+my $init_data = fd_retrieve(\*STDIN);
 
 warn "[fs_signupd] Creating $fs_signupd_socket\n" if $Debug;
 my $uaddr = sockaddr_un($fs_signupd_socket);
@@ -83,56 +42,28 @@ for ( ; $paddr = accept(Client,Server); close Client) {
   chop( my $command = <Client> );
 
   if ( $command eq "signup_info" ) {
+
     warn "[fs_signupd] sending signup info...\n" if $Debug; 
-    print Client join("\n", $n_cust_main_county,
-      map {
-        $_->{taxnum},
-        $_->{state},
-        $_->{county},
-        $_->{country},
-      } @cust_main_county
-    ), "\n";
-
-    print Client join("\n", $n_part_pkg,
-      map {
-        $_->{pkgpart},
-        $_->{pkg},
-      } @part_pkg
-    ), "\n";
-
-    print Client join("\n", $n_svc_acct_pop,
-      map {
-        $_->{popnum},
-        $_->{city},
-        $_->{state},
-        $_->{ac},
-        $_->{exch},
-        $_->{loc},
-      } @svc_acct_pop
-    ), "\n";
+    nstore_fd($init_data, \*Client) or die "can't send init data: $!";
+    Client->flush;
 
   } elsif ( $command eq "new_customer" ) {
+
+    #inefficient...
+
     warn "[fs_signupd] reading customer signup...\n" if $Debug;
-    my(
-      $first, $last, $ss, $company, $address1, $address2, $city, $county,
-      $state, $zip, $country, $daytime, $night, $fax, $payby, $payinfo,
-      $paydate, $payname, $invoicing_list, $referral_custnum,
-      $pkgpart, $username, $password, $popnum,
-    ) = map { scalar(<Client>) } ( 1 .. 24 );
+    my $signup_data = fd_retrieve(\*Client);
 
     warn "[fs_signupd] sending customer data to remote server...\n" if $Debug;
-    print 
-      $first, $last, $ss, $company, $address1, $address2, $city, $county,
-      $state, $zip, $country, $daytime, $night, $fax, $payby, $payinfo,
-      $paydate, $payname, $invoicing_list, $referral_custnum,
-      $pkgpart, $username, $password, $popnum,
-    ;
+    nstore_fd($signup_data, \*STDOUT) or die "can't send signup data: $!";
+    STDOUT->flush;
 
     warn "[fs_signupd] reading error from remote server...\n" if $Debug;
     my $error = <STDIN>;
 
     warn "[fs_signupd] sending error to local client...\n" if $Debug;
     print Client $error;
+    Client->flush;
 
   } else {
     die "unexpected command from client: $command";
index 690f584..b613695 100644 (file)
@@ -8,7 +8,7 @@
 
 BEGIN { $| = 1; print "1..1\n"; }
 END {print "not ok 1\n" unless $loaded;}
-use FS::SignupClient;
+#blah#use FS::SignupClient;
 $loaded = 1;
 print "ok 1\n";
 
index b0d28be..77f7f30 100755 (executable)
@@ -5,6 +5,7 @@
 
 use strict;
 use IO::Handle;
+use Storable qw(nstore_fd fd_retrieve);
 use Tie::RefHash;
 use Net::SSH qw(sshopen2);
 use FS::UID qw(adminsuidsetup);
@@ -14,7 +15,7 @@ use FS::cust_main;
 
 use vars qw( $opt $Debug );
 
-$Debug = 0;
+$Debug = 2;
 
 my @payby = qw(CARD PREPAY);
 
@@ -25,7 +26,7 @@ my $machine = shift or die &usage;
 
 my $agentnum = shift or die &usage;
 my $agent = qsearchs( 'agent', { 'agentnum' => $agentnum } ) or die &usage;
-my $pkgpart = $agent->pkgpart_hashref;
+my $pkgpart_href = $agent->pkgpart_hashref;
 
 my $refnum = shift or die &usage;
 
@@ -36,61 +37,43 @@ my($fs_signupd)="/usr/local/sbin/fs_signupd";
 
 while (1) {
   my($reader,$writer)=(new IO::Handle, new IO::Handle);
-  $writer->autoflush(1);
+  #seems to be broken - calling ->flush explicitly# $writer->autoflush(1);
   warn "[fs_signup_server] Connecting to $machine...\n" if $Debug;
   sshopen2($machine,$reader,$writer,$fs_signupd);
 
-  my $data;
-
-  warn "[fs_signup_server] Sending locales...\n" if $Debug;
-  my @cust_main_county = qsearch('cust_main_county', {} );
-  print $writer $data = join("\n",
-    ( scalar(@cust_main_county) || die "no tax rates (cust_main_county records)" ),
-    map {
-      $_->taxnum,
-      $_->state,
-      $_->county,
-      $_->country,
-    } @cust_main_county
-  ),"\n";
-  warn "[fs_signup_server] $data\n" if $Debug > 2;
-
-  warn "[fs_signup_server] Sending package definitions...\n" if $Debug;
-  my @part_pkg = grep { $_->svcpart('svc_acct') && $pkgpart->{ $_->pkgpart } }
-    qsearch( 'part_pkg', { 'disabled' => '' } );
-  print $writer $data = join("\n",
-    ( scalar(@part_pkg) || die "no usable package definitions, agent $agentnum" ),
-    map {
-      $_->pkgpart,
-      $_->pkg,
-    } @part_pkg
-  ), "\n";
-  warn "[fs_signup_server] $data\n" if $Debug > 2;
-
-  warn "[fs_signup_server] Sending POPs...\n" if $Debug;
-  my @svc_acct_pop = qsearch ('svc_acct_pop',{} );
-  print $writer $data = join("\n",
-    ( scalar(@svc_acct_pop) || die "No points of presence (svc_acct_pop records)" ),
-    map {
-      $_->popnum,
-      $_->city,
-      $_->state,
-      $_->ac,
-      $_->exch,
-      $_->loc,
-    } @svc_acct_pop
-  ), "\n";
-  warn "[fs_signup_server] $data\n" if $Debug > 2;
+  my $init_data = {
+
+    #'_protocol' => 'signup',
+    #'_version' => '0.1',
+    #'_packet' => 'init'
+  
+    'cust_main_county' =>
+      [ map { $_->hashref } qsearch('cust_main_county', {}) ],
+      
+    'part_pkg' =>
+      [
+        map { $_->hashref }
+          grep { $_->svcpart('svc_acct') && $pkgpart_href->{ $_->pkgpart } }
+            qsearch( 'part_pkg', { 'disabled' => '' } )
+      ],
+
+    'svc_acct_pop' => [ map { $_->hashref } qsearch ('svc_acct_pop',{} ) ],
+
+  };
+
+  warn "[fs_signup_server] Sending init data...\n" if $Debug;
+  nstore_fd($init_data, $writer) or die "can't send init data: $!";
+  $writer->flush;
 
   warn "[fs_signup_server] Entering main loop...\n" if $Debug;
   while (1) {
     warn "[fs_signup_server] Reading (waiting for) signup data...\n" if $Debug;
-    chop( my(
-      $first, $last, $ss, $company, $address1, $address2, $city, $county,
-      $state, $zip, $country, $daytime, $night, $fax, $payby, $payinfo,
-      $paydate, $payname, $invoicing_list, $referral_custnum,
-      $pkgpart, $username, $password, $popnum,
-    ) = map { scalar(<$reader>) } ( 1 .. 24 ) );
+    my $signup_data = fd_retrieve($reader);
+
+    if ( $Debug > 1 ) {
+      warn join('',
+        map { "  $_ => ". $signup_data->{$_}. "\n" } keys %$signup_data );
+    }
 
     warn "[fs_signup_server] Processing signup...\n" if $Debug;
 
@@ -99,64 +82,47 @@ while (1) {
     #shares some stuff with htdocs/edit/process/cust_main.cgi... take any
     # common that are still here and library them.
     my $cust_main = new FS::cust_main ( {
-      'custnum'          => '',
+      #'custnum'          => '',
       'agentnum'         => $agentnum,
       'refnum'           => $refnum,
-      'last'             => $last,
-      'first'            => $first,
-      'ss'               => $ss,
-      'company'          => $company,
-      'address1'         => $address1,
-      'address2'         => $address2,
-      'city'             => $city,
-      'county'           => $county,
-      'state'            => $state,
-      'zip'              => $zip,
-      'country'          => $country,
-      'daytime'          => $daytime,
-      'night'            => $night,
-      'fax'              => $fax,
-      'payby'            => $payby,
-      'payinfo'          => $payinfo,
-      'paydate'          => $paydate,
-      'payname'          => $payname,
-      'referral_custnum' => $referral_custnum,
+
+      map { $_ => $signup_data->{$_} } qw(
+        last first ss company address1 address2 city county state zip country
+        daytime night fax payby payinfo paydate payname referral_custnum
+      ),
+
     } );
 
-    $error = "Illegal payment type" unless grep { $_ eq $payby } @payby;
+    $error = "Illegal payment type"
+      unless grep { $_ eq $signup_data->{'payby'} } @payby;
 
-    my @invoicing_list = split( /\s*\,\s*/, $invoicing_list );
+    my @invoicing_list = split( /\s*\,\s*/, $signup_data->{'invoicing_list'} );
 
-    my $part_pkg = qsearchs( 'part_pkg', { 'pkgpart' => $pkgpart } )
-      or $error ||= "WARNING: unknown pkgpart $pkgpart";
+    my $part_pkg =
+      qsearchs( 'part_pkg', { 'pkgpart' => $signup_data->{'pkgpart'} } )
+        or $error ||= "WARNING: unknown pkgpart ". $signup_data->{pkgpart};
     my $svcpart = $part_pkg->svcpart unless $error;
 
     # this should wind up in FS::cust_pkg!
     my $agent = qsearchs( 'agent', { 'agentnum' => $agentnum } );
-    my $pkgpart_href = $agent->pkgpart_hashref;
-    $error ||= "WARNING: agent $agentnum can't purchase pkgpart $pkgpart"
-      unless $pkgpart_href->{ $pkgpart };
+    #my $pkgpart_href = $agent->pkgpart_hashref;
+    $error ||= "WARNING: agent $agentnum can't purchase pkgpart ".
+               $signup_data->{pkgpart}
+      unless $pkgpart_href->{ $signup_data->{pkgpart} };
 
     my $cust_pkg = new FS::cust_pkg ( {
       #later#'custnum' => $custnum,
-      'pkgpart' => $pkgpart,
+      'pkgpart' => $signup_data->{'pkgpart'},
     } );
     $error ||= $cust_pkg->check;
 
     my $svc_acct = new FS::svc_acct ( {
       'svcpart'   => $svcpart,
-      'username'  => $username,
-      '_password' => $password,
-      'popnum'    => $popnum,
+      map { $_ => $signup_data->{$_} } qw( username _password popnum ),
     } );
 
     my $y = $svc_acct->setdefault; # arguably should be in new method
     $error ||= $y unless ref($y);
-    #and just in case you were silly
-    $svc_acct->svcpart($svcpart);
-    $svc_acct->username($username);
-    $svc_acct->_password($password);
-    $svc_acct->popnum($popnum);
 
     $error ||= $svc_acct->check;
 
index 4370d91..2ad75b1 100644 (file)
@@ -52,6 +52,7 @@ Before installing, you need:
       <li><a href="http://search.cpan.org/search?dist=Tie-IxHash">Tie-IxHash</a>
       <li><a href="http://search.cpan.org/search?dist=Time-Duration">Time-Duration</a>
       <li><a href="http://search.cpan.org/search?dist=HTML-Widgets-SelectLayers">HTML-Widgets-SelectLayers</a>
+      <li><a href="http://search.cpan.org/search?dist=Storable">Storable</a>
     </ul>
 </ul>
 Install the Freeside distribution:
index 262b697..e747215 100644 (file)
@@ -9,7 +9,8 @@ webserver.  On this machine, install:
   <li>A web server, such as <a href="http://www.apache-ssl.org">Apache-SSL</a> or <a href="http://www.apache.org">Apache</a>
   <li><a href="ftp://ftp.cs.hut.fi/pub/ssh/">SSH</a>
   <li><a href="http://www.perl.com/CPAN/doc/relinfo/INSTALL.html">Perl</a> (at least 5.004_05 for the 5.004 series or 5.005_03 for the 5.005 series.  Don't enable experimental features like threads or the PerlIO abstraction layer.)
-  <li><a href="http://www.perl.com/CPAN/modules/by-module/Text/">Text::Template</a>
+  <li><a href="http://search.cpan.org/search?dist=Text-Template">Text::Template</a>
+  <li><a href="http://search.cpan.org/search?dist=Storable">Storable</a>
   <li><a href="http://www.sisd.com/useragent">HTTP::Headers::UserAgent</a> (version 2.0 or higher; not yet indexed correctly on CPAN)
 
   <li><a href="man/FS/SignupClient.html">FS::SignupClient</a> (copy the fs_signup/FS-SignupClient directory to the external machine, then: perl Makefile.PL; make; make install)