/[soft]/identity/CatDap/trunk/lib/CatDap/Controller/admin.pm
ViewVC logotype

Contents of /identity/CatDap/trunk/lib/CatDap/Controller/admin.pm

Parent Directory Parent Directory | Revision Log Revision Log


Revision 559 - (show annotations) (download)
Tue Feb 15 23:01:21 2011 UTC (13 years, 2 months ago) by buchan
File size: 22057 byte(s)
Fix display of non-ascii characters (may need some work in admin for multi-valued attributes)

1 package CatDap::Controller::admin;
2 use Moose;
3 use namespace::autoclean;
4 use Data::UUID;
5 #use Data::Dumper;
6 use Net::LDAP::Extension::SetPassword;
7
8 BEGIN { extends 'Catalyst::Controller'; }
9
10 =head1 NAME
11
12 CatDap::Controller::admin - Catalyst Controller
13
14 =head1 DESCRIPTION
15
16 Catalyst Controller.
17
18 =head1 METHODS
19
20 =cut
21
22 =head2 auto
23
24 Ensure the user is logged in. In order to bind as the user, we use
25 CatDap::Model::User, which uses Catalyst::Model::LDAP::FromAuthentication,
26 which effectively requires calling $c->authenticate on every request.
27
28 To do this, we keep the password, encrypted with blowfish, using the
29 (for now), first 3 octets of IPv4 request address and a UUID string (stored in
30 a cookie) as the key. To access the password, an attacker would need:
31 - the first 3 octets of IPv4 request (not stored anywhere, but accessible
32 in server logs)
33 - the encrpyted password (only available server-side in the session variable)
34 - the UUID key portion (only available on the browser-side in a cookie)
35
36 So, if the user does "not exist", we authenticate them, if it succeeds we encrypt
37 the password and store it in the session.
38
39 If the user is logged in, we get the encrypted password from the session, decrypt
40 it (we need to handle failure to decrypt it better)
41
42 =cut
43
44 sub auto : Private {
45 my ( $self, $c ) = @_;
46 my $cipher;
47 my $password;
48 my $mesg;
49 my $dn;
50
51 # TODO merge this code with the one in user.pm
52 my $keyprefix;
53 if ($c->req->address =~ m/:/) {
54 my $address = $c->req->address;
55 $address =~ s/\[\]://;
56 $keyprefix = sprintf( "%06x", $address >> 104 ); # if we shift right 104 bits from 128 we have 24 bits left or 3 bytes.
57 }
58 else {
59 $keyprefix = sprintf( "%02x%02x%02x", split /\./, $c->req->address );
60 }
61 if ( !defined $c->user or not $c->req->cookie('key') ) {
62 $c->detach('/user/login')
63 if ( not $c->req->param('username')
64 or not $c->req->param('password') );
65 $c->log->debug("No session, logging user in");
66 if (
67 !$c->authenticate(
68 {
69 username => $c->req->param('username'),
70 password => $c->req->param('password')
71 || $c->req->param('key')
72 }
73 )
74 )
75 {
76
77 #TODO: ppolicy ....
78 $c->stash( errors => ['Incorrect username or password'] );
79 $c->stash( template => 'index.tt' );
80 $c->log->debug("Logging user in failed, forwarding to login page");
81 $c->visit('/user/login');
82 $c->detach;
83 return 1;
84 }
85 else {
86
87 #if (defined $c->user->pwdReset) {
88 # $c->res->redirect('/user');
89 #}
90 #$c->persist_user;
91 $c->log->debug('Logging user in to LDAP');
92 my $ug = Data::UUID->new;
93 my $key = $ug->create_str();
94 $cipher = Crypt::CBC->new(
95 -key => $keyprefix . $key,
96 -cipher => 'Blowfish'
97 ) or die $!;
98 $c->session->{enc_password} =
99 $cipher->encrypt( $c->req->param('password')
100 || $c->req->param('key') );
101 $c->stash( pages => roles2pages( $c->user->roles ) );
102 $c->session->{dn} = $c->user->ldap_entry->dn;
103 $c->session->{user} = $c->req->param('username');
104 $password = $c->req->param('password') || $c->req->param('key');
105 return 1;
106 }
107
108 }
109 else {
110 my $key = $c->req->cookie('key')->value;
111 $cipher = Crypt::CBC->new(
112 -key => $keyprefix . $key,
113 -cipher => 'Blowfish'
114 ) or die $!;
115 $password = $cipher->decrypt( $c->session->{enc_password} );
116 $c->log->debug( "Re-authenticating user " . $c->session->{user} );
117 $c->authenticate(
118 { username => $c->session->{user}, password => $password } )
119 or $c->view('/user/login');
120 $c->res->cookies->{'key'} = {value => $key, expires => '+10m'};
121 $c->stash( pages => roles2pages( $c->user->roles ) );
122 $c->log->info($@) if $@;
123 return 1;
124 }
125 }
126
127 sub account : Local {
128 my ( $self, $c ) = @_;
129 $c->detach('/user/login') if not $c->user;
130 $c->assert_user_roles('Account Admins');
131 $c->stash( subpages => gensubpages('account') );
132 my @errors;
133 return
134 if not defined $c->req->param('attribute')
135 and not defined $c->req->param('value');
136
137 #my $attribute =~ m/^([\w\d]*)/,$c->req->param('attribute');
138 my $attribute = $c->req->param('attribute');
139 $c->log->debug("Searching for account using attribute $attribute");
140
141 #my $value =~ /^[\w\d]*/,$c->req->param('attribute');
142 my $value = $c->req->param('value');
143 my $mesg =
144 $c->model('user')
145 ->search("(&(objectClass=inetOrgPerson)($attribute=$value))");
146 my @orig_entries = $mesg->entries;
147 my @entries;
148 foreach my $entry (@orig_entries) {
149 my %new_entry;
150 foreach my $attr ($entry->attributes) {
151 $new_entry{$attr} = Encode::decode_utf8($entry->get_value($attr));
152 }
153 push @entries, \%new_entry;
154 }
155 push @errors, $mesg->error if $mesg->code;
156 $c->stash( entries => \@entries );
157 $c->stash( errors => \@errors );
158 }
159
160 sub account_promote : Local {
161 my ( $self, $c ) = @_;
162 $c->detach('/user/login') if not $c->user;
163 $c->assert_user_roles('Account Admins');
164 $c->stash( subpages => gensubpages('account') );
165 my @errors;
166 if ( defined $c->req->param('gid') and defined $c->req->param('username') )
167 {
168 my $gid = $c->req->param('gid');
169 foreach my $uid ( $c->req->param('username') ) {
170 $c->log->info("Promoting user $uid with gid $gid");
171 my $mesg =
172 $c->model('user')
173 ->search(
174 "(&(uid=$uid)(objectclass=inetOrgPerson)(!(objectClass=posixAccount)))"
175 );
176 if ( $mesg->entries gt 1 ) {
177 push @errors, "More than one account matched user $uid";
178
179 #TODO forward to error page
180 }
181 my $entry = $mesg->entry;
182 $mesg = $c->model('user')->search("(objectclass=sambaUnixIdPool)");
183 if ( $mesg->entries gt 1 ) {
184 push @errors, "More than one ID Pool";
185
186 #TODO forward to error page
187 }
188 my $idpool = $mesg->entry;
189 my $uidnum = $idpool->uidNumber;
190 my $newuidnum = $uidnum+1;
191 $entry->add(
192 objectclass => [ 'posixAccount', 'ldapPublicKey' ],
193 loginShell => '/bin/bash',
194 gidNumber => $c->req->param('gid'),
195 uidNumber => $uidnum,
196 homeDirectory => "/home/$uid",
197 );
198 $idpool->replace( uidNumber => $newuidnum );
199
200 $idpool->update;
201 $mesg = $entry->update;
202 if ($mesg->code) {
203 $c->log->info('LDAP update failed: ' . $mesg->error);
204 push @errors, $mesg->error;
205 #reverse idpool update
206 $idpool->replace( uidNumber => $uidnum );
207 $mesg = $idpool->update;
208 $c->log->info("ERROR IdPool could not be reset to $uidnum: ", $mesg->error) if $mesg->code;
209 }
210 }
211 }
212 my $mesg =
213 $c->model('user')
214 ->search("(&(objectClass=inetOrgPerson)(!(objectClass=posixAccount)))");
215 my @entries = $mesg->entries;
216 $c->stash( entries => \@entries );
217 push @errors, $mesg->error if $mesg->code;
218 $mesg = $c->model('user')->search("(objectClass=posixGroup)");
219 my @groups = $mesg->entries;
220 $c->stash( groups => \@groups );
221 $c->stash( errors => \@errors );
222 }
223
224 sub account_modify : Local {
225 my ( $self, $c, $user ) = @_;
226 $c->detach('/user/login') if not $c->user;
227 $c->stash( subpages => gensubpages('account') );
228 $c->assert_user_roles('Account Admins');
229 my @errors;
230 my $mesg;
231 if ( $user eq '' ) {
232 $c->forward( $c->uri_for('/account') );
233 $c->detach;
234 }
235 my $schemaldap = Net::LDAP->new( ${ $c->config }{'Model::Proxy'}{'host'} )
236 or warn "LDAP bind failed: $!";
237 $schemaldap->start_tls if ${ $c->config }{'Model::Proxy'}{'start_tls'};
238 $schemaldap->bind;
239 my $schema = $schemaldap->schema or die("Searching schema failed: $!");
240 my $attrdef;
241
242 my $entry;
243 $c->log->debug("Searching for user $user");
244 $mesg =
245 $c->model('User')->search("(&(objectclass=inetOrgPerson)(uid=$user))");
246 $entry = $mesg->entry;
247
248 # Handle adding attributes immediately here, forwarding back to ourselves
249 if ( $c->req->param('operation') eq 'add' ) {
250 $entry->add( $c->req->param('attribute') => $c->req->param('value') );
251 $mesg = $entry->update;
252 push @errors, $mesg->error if $mesg->code;
253 $c->res->redirect( $c->uri_for( $c->req->uri ) . "/$user" );
254 }
255
256 my %mods;
257 my %params = %{ $c->req->parameters };
258 my $update = 0;
259 foreach my $req ( keys %params ) {
260 next if $req !~ /(.+)_new/;
261 my $attrname = $1;
262 next if $params{ $attrname . '_new' } eq $params{ $attrname . '_old' };
263 $c->log->debug("Received update request for attribute $attrname");
264 $update = 1;
265 $attrdef = $schema->attribute($attrname)
266 or die("getting schema failed: $!");
267 if ( $$attrdef{'single-value'} ) {
268 $entry->replace( $attrname => $params{ $attrname . '_new' } )
269 or $c->log->info($!);
270 }
271 else {
272 $entry->delete( $attrname => $params{ $attrname . '_old' } );
273 $entry->add( $attrname => $params{ $attrname . '_new' } );
274 }
275 if ($update) {
276 $mesg = $entry->update;
277 push @{ ${ $c->stash }{'errors'} }, $mesg->error if $mesg->code;
278 }
279 }
280
281 $mesg =
282 $c->model('User')->search("(&(objectclass=inetOrgPerson)(uid=$user))");
283 $c->log->debug( $mesg->error ) if $mesg->code;
284 $entry = $mesg->entry;
285 $c->log->debug( $mesg->error ) if $mesg->code;
286
287 my @values;
288 my @attributes = $entry->attributes;
289 my @may;
290 my @addable_attrs = @attributes;
291 my @ocs;
292 my @must;
293 @ocs = $entry->get_value("objectClass");
294 foreach my $oc (@ocs) {
295 foreach my $attr ( $schema->must($oc) ) {
296 push @must, $$attr{'name'} if not grep /$$attr{'name'}/, @must;
297 }
298 }
299
300 foreach my $attr ( sort @attributes ) {
301 next if ( $attr eq "objectClass" );
302 next
303 if grep /$attr/,
304 @{ ${ $c->config }{'Controller::User'}{'skip_attrs'} };
305 my @vals = $entry->get_value($attr);
306 foreach (@vals) { $_ = Encode::decode_utf8( $_ ); }
307 $attrdef = $schema->attribute($attr)
308 or die("getting schema failed: $!");
309 my %valhash = (
310 name => $attr,
311 values => \@vals,
312 desc => $$attrdef{'desc'},
313 );
314 if ( !grep /^$attr$/,
315 @{ ${ $c->config }{'Controller::User'}{'uneditable_attrs'} } )
316 {
317 $valhash{'editable'} = 1;
318 }
319 if ( !$$attrdef{'single-value'} && $valhash{'editable'} ) {
320 $valhash{'addable'} = 1;
321 }
322 if ( !grep /$attr/, @must ) { $valhash{'removable'} = 1; }
323 push @values, \%valhash;
324 }
325 foreach my $oc (@ocs) {
326 foreach my $attrdef ( $schema->may($oc) ) {
327 my $attrname = $$attrdef{'name'};
328 grep /$attrname/, @may
329 or grep /$attrname/, @attributes
330 or grep /$attrname/,
331 @{ ${ $c->config }{'Controller::User'}{'uneditable_attrs'} }
332 or grep /$attrname/,
333 @{ ${ $c->config }{'Controller::User'}{'skip_attrs'} }
334 or push @may, $attrname;
335 }
336 }
337 @may = sort @may;
338 my @available_ocs = $schema->all_objectclasses;
339 my @offer_ocs;
340 foreach my $oc (@available_ocs) {
341 my $ocname = $$oc{name};
342 next if grep /$ocname/, @ocs;
343 next if not $$oc{auxiliary};
344 push @offer_ocs, $ocname;
345 }
346 @offer_ocs = sort @offer_ocs;
347 my @groups;
348 if ( grep /posixAccount/, @offer_ocs ) {
349 my $mesg = $c->model('user')->search('objectclass=posixGroup');
350 foreach my $group ( $mesg->entries ) {
351 push @groups,
352 {
353 name => $group->cn,
354 gidNumber => $group->gidNumber,
355 };
356 }
357 }
358
359 $c->stash(
360 {
361 username => $user,
362 values => \@values,
363 attrdef => $attrdef,
364 may => \@may,
365 must => \@must,
366 offer_ocs => \@offer_ocs,
367 dn => $entry->dn,
368 uid => $entry->uid,
369 }
370 );
371 $c->stash( 'groups' => \@groups ) if (@groups);
372 }
373
374 sub account_modifydel : Local {
375 my ( $self, $c, $uid, $attr, $value ) = @_;
376 $c->detach('/user/login') if not $c->user;
377 $c->assert_user_roles('Account Admins');
378 $c->stash( subpages => gensubpages('account') );
379 my @errors;
380 my $mesg;
381 $mesg =
382 $c->model('user')->search("(&(objectClass=inetOrgPerson)(uid=$uid))");
383 push @errors, $mesg->error if $mesg->code;
384 $mesg = $mesg->entry->delete( $attr => $value )->update;
385 push @errors, $mesg->error if $mesg->code;
386 $c->res->redirect( $c->uri_for('/admin/account_modify') . "/$uid" );
387 }
388
389 sub account_group : Local {
390 my ( $self, $c, $uid ) = @_;
391 $c->detach('/user/login') if not $c->user;
392 $c->assert_user_roles('Account Admins');
393 $c->stash( subpages => gensubpages('account') );
394 $c->res->redirect($c->uri_for('/admin/account')) if $uid eq '';
395 my (@errors,@newgroups,@groups);
396 my ($mesg,$entry,$dn);
397
398 $mesg = $c->model('user')->search("(&(objectclass=inetOrgperson)(uid=$uid))");
399 $entry = $mesg->entry;
400 $dn = $entry->dn;
401 if (defined $c->req->param('op')) {
402 my $group = $c->req->param('group');
403 $mesg = $c->model('user')->search("(&(objectclass=groupOfNames)(cn=$group))");
404 $entry = $mesg->entry;
405 $entry->delete(member => $dn) if ($c->req->param('op') eq 'delete');
406 $entry->add(member => $dn) if ($c->req->param('op') eq 'add');
407 $mesg = $entry->update if ($entry->changes);
408 push @errors,$mesg->error if $mesg->code;
409 }
410
411
412 $mesg = $c->model('user')->search("(&(objectclass=groupOfNames)(member=$dn))");
413 @groups = $mesg->entries;
414 $mesg = $c->model('user')->search("(&(objectclass=groupOfNames)(!(member=$dn)))");
415 @newgroups = $mesg->entries;
416 $c->stash(
417 uid => $uid,
418 groups => \@groups,
419 newgroups => \@newgroups,
420 );
421 }
422
423 sub account_addoc : Local {
424 my ( $self, $c ) = @_;
425 $c->detach('/user/login') if not $c->user;
426 $c->assert_user_roles('Account Admins');
427 $c->stash( subpages => gensubpages('account') );
428 my $objectclass = $c->req->param('objectclass')
429 or $c->detach('/admin/group');
430 my $dn = $c->req->param('dn');
431 my $uid = $c->req->param('uid');
432 my ( @errors, @must, @may );
433 my $mesg;
434 my $schemaldap = Net::LDAP->new( ${ $c->config }{'Model::Proxy'}{'host'} )
435 or warn "LDAP bind failed: $!";
436 $mesg = $schemaldap->start_tls
437 if ${ $c->config }{'Model::Proxy'}{'start_tls'};
438 push @errors, $mesg->error if $mesg->code;
439 $schemaldap->bind;
440 push @errors, $mesg->error if $mesg->code;
441 my $schema = $schemaldap->schema or die("Searching schema failed: $!");
442 $mesg =
443 $c->model('user')->search("(&(objectclass=inetOrgPerson)(uid=$uid))");
444 $c->log->debug( $mesg->error ) if $mesg->code;
445 my $entry = $mesg->entry;
446 $c->log->debug( $mesg->error ) if $mesg->code;
447
448 foreach my $attr ( $schema->must($objectclass) ) {
449 push @must, $$attr{name} if not $entry->get_value( $$attr{name} );
450 }
451 foreach my $attr ( $schema->may($objectclass) ) {
452 push @may, $$attr{name} if not $entry->get_value( $$attr{name} );
453 }
454
455 # if we have all the musts as params
456 my $haveall = 1;
457 foreach my $addattr (@must) {
458 if ( defined $c->req->param($addattr) ) {
459 $entry->add( $addattr => $c->req->param($addattr) );
460 }
461 else {
462 $c->log->debug("Missing attribute $addattr");
463 $haveall = 0;
464 }
465 }
466 if ($haveall) {
467 $entry->add( objectClass => [$objectclass] );
468 $c->log->debug("About to push updates to $dn");
469 #$c->log->debug( Dumper( \$entry->changes ) );
470 $mesg = $entry->update;
471 push @errors, $mesg->error if $mesg->code;
472 $c->stash( template => 'admin/account.tt', errors => @errors );
473
474 #$c->detach('account_modify');
475 $c->res->redirect( $c->uri_for('/admin/account_modify') . "/" . $uid );
476 $c->detach;
477 }
478 $c->stash(
479 may => \@may,
480 must => \@must,
481 oc => $objectclass,
482 dn => $dn,
483 uid => $uid,
484 );
485 }
486
487 sub password : Local {
488 my ($self,$c,$uid) = @_;
489 $c->detach('/user/login') if not $c->user;
490 $c->assert_user_roles('Account Admins');
491 $c->stash( subpages => gensubpages('account') );
492 my (@errors,@mail);
493 my ($mesg,$entry,$newpass);
494 $c->res->redirect($c->uri_for('/admin/account')) if $uid eq '';
495
496 # Find the user so we have their email now for confirmation, or can easily
497 # set password if reset has been confirmed
498 $mesg = $c->model('user')->search("uid=$uid");
499 push @errors,$mesg->error if $mesg->code;
500 if ($mesg->entries gt 1) {
501 push @errors,'More than one entry matched';
502 $c->detach;
503 }
504 $entry = $mesg->entry;
505 @mail = $entry->mail;
506
507 if (!$c->req->param('txnid')) {
508 my $txnid = Data::UUID->new->create_str();
509 $c->session(txnid => $txnid);
510 $c->stash( uid => $uid, txnid => $txnid, mails => @mail);
511 return 1;
512 }
513 if ($c->req->param('txnid') ne $c->session->{txnid}) {
514 push @errors,'Transaction ID mismatch';
515 $c->detach;
516 }
517 $newpass = Data::UUID->new->create_str();
518 my $pp = Net::LDAP::Control::PasswordPolicy->new;
519 $mesg = $entry->replace( userPassword => $newpass,pwdReset => 'TRUE' )->update;
520 if ($mesg->code) {
521 push @errors,"Password reset failed: " . $mesg->error;
522 $c->detach;
523 }
524 my $email = join(',',@mail);
525 # send the mail in the users preferred language
526 my @oldlang = $c->languages;
527 if ($entry->preferredLanguage) {
528 #$c->log->debug("Setting languages to: " . Dumper($entry->preferredLanguage));
529 $c->languages([$entry->preferredLanguage]);
530 }
531 $c->stash(
532 email => {
533 'to' => $email,
534 'subject' => $c->config->{apptitle} . " - "
535 . $c->loc('password reset'),
536 'from' => $c->config->{emailfrom},
537 'template' => 'admin/password.tt',
538 'content_type' => 'text/plain',
539 },
540 entry => $entry,
541 url => $c->uri_for('/user/firstlogin') . "/?username=$uid&key=$newpass",
542 );
543 $c->forward( $c->view('Email::Template') );
544 $c->languages(@oldlang);
545 if ( @{ $c->error } ) {
546 my $errors = join "\n",@{ $c->error };
547 $c->log->info("Sending reset mail to $email failed: $errors");
548 $c->response->body($c->loc('An error occured sending the email, but your account was created. Please try the password recovery process if you entered the correct email address: [_1]', $errors));
549 $c->error(0); # Reset the error condition if you need to
550 }
551 push @errors,$c->loc('Password reset and email sent');
552 $c->res->redirect($c->uri_for('/admin/account_modify') . "/$uid");
553 }
554
555
556 sub group : Local {
557 my ( $self, $c ) = @_;
558 $c->detach('/user/login') if not $c->user;
559 $c->assert_user_roles('Account Admins');
560 $c->stash( subpages => gensubpages('account') );
561 my @errors;
562 return if not $c->req->param('attribute') and not $c->req->param('value');
563 my $attribute = $c->req->param('attribute');
564 $attribute =~ s/[^\w\d]//g;
565 my $value = $c->req->param('value');
566 $value =~ s/[^\w\d\*]//g;
567 my $mesg =
568 $c->model('user')
569 ->search("(&(objectclass=posixGroup)($attribute=$value))");
570 push @errors, $mesg->error if $mesg->code;
571 my @entries = $mesg->entries;
572 push @errors, $mesg->error if $mesg->code;
573 $c->stash(
574 entries => \@entries,
575 errors => \@errors,
576 );
577 }
578
579 sub group_modify : Local {
580 my ( $self, $c, $group ) = @_;
581 $c->detach('/user/login') if not $c->user;
582 $c->assert_user_roles('Account Admins');
583 $c->stash( subpages => gensubpages('account') );
584 my @errors;
585 $c->detach('/admin/group') if $group eq '';
586 if ( $group !~ /^[\w\d]*$/ ) {
587 push @errors, "Group contains illegal characters";
588 $c->detach('admin/group');
589 }
590 my $mesg =
591 $c->model('user')->search("(&(objectClass=posixGroup)(cn=$group))");
592 if ( $mesg->entries gt 1 ) {
593 push @errors, 'More than one entry matched';
594 $c->detach('/admin/group');
595 }
596 $c->stash( group => $mesg->entry );
597 }
598
599 =head2 index
600
601 =cut
602
603 sub index : Path : Args(0) {
604 my ( $self, $c ) = @_;
605 $c->detach('/index') if not $c->user;
606 $c->assert_user_roles('Account Admins');
607 $c->stash( pages => roles2pages( $c->user->roles ) );
608
609 #$c->response->body("Matched CatDap::Controller::admin in admin, roles $rolelist");
610 }
611
612 sub roles2pages : Private {
613 my @roles = @_;
614 my @pages;
615 foreach my $role ( sort @roles ) {
616 if ( $role =~ /^(\w+) ?(\w*) (Admin|User)s$/ ) {
617 my $page = lc("/$3/$1$2");
618 push @pages, { page => lc("/$3/$1$2"), title => "$1 $2 $3" };
619 }
620 }
621 return \@pages;
622 }
623
624 sub gensubpages : Private {
625 my ($type) = @_;
626 my @subpagenames;
627 if ( $type eq 'account' ) {
628 @subpagenames = (
629 { page => 'account', title => "Users" },
630 { page => 'account_promote', title => "Promote" },
631 #{ page => 'account_unlock', title => "Unlock" },
632 { page => 'group', title => "Groups" },
633 );
634 }
635 return \@subpagenames;
636 }
637
638 =head1 AUTHOR
639
640 Buchan Milne
641
642 =head1 LICENSE
643
644 This library is free software. You can redistribute it and/or modify
645 it under the same terms as Perl itself.
646
647 =cut
648
649 __PACKAGE__->meta->make_immutable;
650
651 1;

  ViewVC Help
Powered by ViewVC 1.1.30