allow + in md5 encrypted passwords
[freeside.git] / FS / FS / svc_acct.pm
1 package FS::svc_acct;
2
3 use strict;
4 use vars qw( @ISA $noexport_hack $conf
5              $dir_prefix @shells $usernamemin
6              $usernamemax $passwordmin $passwordmax
7              $username_ampersand $username_letter $username_letterfirst
8              $username_noperiod $username_nounderscore $username_nodash
9              $username_uppercase
10              $welcome_template $welcome_from $welcome_subject $welcome_mimetype
11              $smtpmachine
12              $dirhash
13              @saltset @pw_set );
14 use Carp;
15 use Fcntl qw(:flock);
16 use FS::UID qw( datasrc );
17 use FS::Conf;
18 use FS::Record qw( qsearch qsearchs fields dbh );
19 use FS::svc_Common;
20 use FS::cust_svc;
21 use FS::part_svc;
22 use FS::svc_acct_pop;
23 use FS::cust_main_invoice;
24 use FS::svc_domain;
25 use FS::raddb;
26 use FS::queue;
27 use FS::radius_usergroup;
28 use FS::export_svc;
29 use FS::part_export;
30 use FS::Msgcat qw(gettext);
31
32 @ISA = qw( FS::svc_Common );
33
34 #ask FS::UID to run this stuff for us later
35 $FS::UID::callback{'FS::svc_acct'} = sub { 
36   $conf = new FS::Conf;
37   $dir_prefix = $conf->config('home');
38   @shells = $conf->config('shells');
39   $usernamemin = $conf->config('usernamemin') || 2;
40   $usernamemax = $conf->config('usernamemax');
41   $passwordmin = $conf->config('passwordmin') || 6;
42   $passwordmax = $conf->config('passwordmax') || 8;
43   $username_letter = $conf->exists('username-letter');
44   $username_letterfirst = $conf->exists('username-letterfirst');
45   $username_noperiod = $conf->exists('username-noperiod');
46   $username_nounderscore = $conf->exists('username-nounderscore');
47   $username_nodash = $conf->exists('username-nodash');
48   $username_uppercase = $conf->exists('username-uppercase');
49   $username_ampersand = $conf->exists('username-ampersand');
50   $dirhash = $conf->config('dirhash') || 0;
51   if ( $conf->exists('welcome_email') ) {
52     $welcome_template = new Text::Template (
53       TYPE   => 'ARRAY',
54       SOURCE => [ map "$_\n", $conf->config('welcome_email') ]
55     ) or warn "can't create welcome email template: $Text::Template::ERROR";
56     $welcome_from = $conf->config('welcome_email-from'); # || 'your-isp-is-dum'
57     $welcome_subject = $conf->config('welcome_email-subject') || 'Welcome';
58     $welcome_mimetype = $conf->config('welcome_email-mimetype') || 'text/plain';
59   } else {
60     $welcome_template = '';
61   }
62   $smtpmachine = $conf->config('smtpmachine');
63 };
64
65 @saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
66 @pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
67
68 sub _cache {
69   my $self = shift;
70   my ( $hashref, $cache ) = @_;
71   if ( $hashref->{'svc_acct_svcnum'} ) {
72     $self->{'_domsvc'} = FS::svc_domain->new( {
73       'svcnum'   => $hashref->{'domsvc'},
74       'domain'   => $hashref->{'svc_acct_domain'},
75       'catchall' => $hashref->{'svc_acct_catchall'},
76     } );
77   }
78 }
79
80 =head1 NAME
81
82 FS::svc_acct - Object methods for svc_acct records
83
84 =head1 SYNOPSIS
85
86   use FS::svc_acct;
87
88   $record = new FS::svc_acct \%hash;
89   $record = new FS::svc_acct { 'column' => 'value' };
90
91   $error = $record->insert;
92
93   $error = $new_record->replace($old_record);
94
95   $error = $record->delete;
96
97   $error = $record->check;
98
99   $error = $record->suspend;
100
101   $error = $record->unsuspend;
102
103   $error = $record->cancel;
104
105   %hash = $record->radius;
106
107   %hash = $record->radius_reply;
108
109   %hash = $record->radius_check;
110
111   $domain = $record->domain;
112
113   $svc_domain = $record->svc_domain;
114
115   $email = $record->email;
116
117   $seconds_since = $record->seconds_since($timestamp);
118
119 =head1 DESCRIPTION
120
121 An FS::svc_acct object represents an account.  FS::svc_acct inherits from
122 FS::svc_Common.  The following fields are currently supported:
123
124 =over 4
125
126 =item svcnum - primary key (assigned automatcially for new accounts)
127
128 =item username
129
130 =item _password - generated if blank
131
132 =item sec_phrase - security phrase
133
134 =item popnum - Point of presence (see L<FS::svc_acct_pop>)
135
136 =item uid
137
138 =item gid
139
140 =item finger - GECOS
141
142 =item dir - set automatically if blank (and uid is not)
143
144 =item shell
145
146 =item quota - (unimplementd)
147
148 =item slipip - IP address
149
150 =item seconds - 
151
152 =item domsvc - svcnum from svc_domain
153
154 =item radius_I<Radius_Attribute> - I<Radius-Attribute>
155
156 =back
157
158 =head1 METHODS
159
160 =over 4
161
162 =item new HASHREF
163
164 Creates a new account.  To add the account to the database, see L<"insert">.
165
166 =cut
167
168 sub table { 'svc_acct'; }
169
170 =item insert
171
172 Adds this account to the database.  If there is an error, returns the error,
173 otherwise returns false.
174
175 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be 
176 defined.  An FS::cust_svc record will be created and inserted.
177
178 The additional field I<usergroup> can optionally be defined; if so it should
179 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
180 sqlradius export only)
181
182 (TODOC: L<FS::queue> and L<freeside-queued>)
183
184 (TODOC: new exports! $noexport_hack)
185
186 =cut
187
188 sub insert {
189   my $self = shift;
190   my $error;
191
192   local $SIG{HUP} = 'IGNORE';
193   local $SIG{INT} = 'IGNORE';
194   local $SIG{QUIT} = 'IGNORE';
195   local $SIG{TERM} = 'IGNORE';
196   local $SIG{TSTP} = 'IGNORE';
197   local $SIG{PIPE} = 'IGNORE';
198
199   my $oldAutoCommit = $FS::UID::AutoCommit;
200   local $FS::UID::AutoCommit = 0;
201   my $dbh = dbh;
202
203   $error = $self->check;
204   return $error if $error;
205
206   #no, duplicate checking just got a whole lot more complicated
207   #(perhaps keep this check with a config option to turn on?)
208
209   #return gettext('username_in_use'). ": ". $self->username
210   #  if qsearchs( 'svc_acct', { 'username' => $self->username,
211   #                             'domsvc'   => $self->domsvc,
212   #                           } );
213
214   if ( $self->svcnum ) {
215     my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
216     unless ( $cust_svc ) {
217       $dbh->rollback if $oldAutoCommit;
218       return "no cust_svc record found for svcnum ". $self->svcnum;
219     }
220     $self->pkgnum($cust_svc->pkgnum);
221     $self->svcpart($cust_svc->svcpart);
222   }
223
224   #new duplicate username checking
225
226   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
227   unless ( $part_svc ) {
228     $dbh->rollback if $oldAutoCommit;
229     return 'unknown svcpart '. $self->svcpart;
230   }
231
232   my @dup_user = qsearch( 'svc_acct', { 'username' => $self->username } );
233   my @dup_userdomain = qsearch( 'svc_acct', { 'username' => $self->username,
234                                               'domsvc'   => $self->domsvc } );
235   my @dup_uid;
236   if ( $part_svc->part_svc_column('uid')->columnflag ne 'F'
237        && $self->username !~ /^(toor|(hyla)?fax)$/          ) {
238     @dup_uid = qsearch( 'svc_acct', { 'uid' => $self->uid } );
239   } else {
240     @dup_uid = ();
241   }
242
243   if ( @dup_user || @dup_userdomain || @dup_uid ) {
244     my $exports = FS::part_export::export_info('svc_acct');
245     my %conflict_user_svcpart;
246     my %conflict_userdomain_svcpart = ( $self->svcpart => 'SELF', );
247
248     foreach my $part_export ( $part_svc->part_export ) {
249
250       #this will catch to the same exact export
251       my @svcparts = map { $_->svcpart }
252         qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
253
254       #this will catch to exports w/same exporthost+type ???
255       #my @other_part_export = qsearch('part_export', {
256       #  'machine'    => $part_export->machine,
257       #  'exporttype' => $part_export->exporttype,
258       #} );
259       #foreach my $other_part_export ( @other_part_export ) {
260       #  push @svcparts, map { $_->svcpart }
261       #    qsearch('export_svc', { 'exportnum' => $part_export->exportnum });
262       #}
263
264       my $nodomain = $exports->{$part_export->exporttype}{'nodomain'};
265       if ( $nodomain =~ /^Y/i ) {
266         $conflict_user_svcpart{$_} = $part_export->exportnum
267           foreach @svcparts;
268       } else {
269         $conflict_userdomain_svcpart{$_} = $part_export->exportnum
270           foreach @svcparts;
271       }
272     }
273
274     foreach my $dup_user ( @dup_user ) {
275       my $dup_svcpart = $dup_user->cust_svc->svcpart;
276       if ( exists($conflict_user_svcpart{$dup_svcpart}) ) {
277         $dbh->rollback if $oldAutoCommit;
278         return "duplicate username: conflicts with svcnum ". $dup_user->svcnum.
279                " via exportnum ". $conflict_user_svcpart{$dup_svcpart};
280       }
281     }
282
283     foreach my $dup_userdomain ( @dup_userdomain ) {
284       my $dup_svcpart = $dup_userdomain->cust_svc->svcpart;
285       if ( exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
286         $dbh->rollback if $oldAutoCommit;
287         return "duplicate username\@domain: conflicts with svcnum ".
288                $dup_userdomain->svcnum. " via exportnum ".
289                $conflict_userdomain_svcpart{$dup_svcpart};
290       }
291     }
292
293     foreach my $dup_uid ( @dup_uid ) {
294       my $dup_svcpart = $dup_uid->cust_svc->svcpart;
295       if ( exists($conflict_user_svcpart{$dup_svcpart})
296            || exists($conflict_userdomain_svcpart{$dup_svcpart}) ) {
297         $dbh->rollback if $oldAutoCommit;
298         return "duplicate uid: conflicts with svcnum". $dup_uid->svcnum.
299                "via exportnum ". $conflict_user_svcpart{$dup_svcpart}
300                                  || $conflict_userdomain_svcpart{$dup_svcpart};
301       }
302     }
303
304   }
305
306   #see?  i told you it was more complicated
307
308   my @jobnums;
309   $error = $self->SUPER::insert(\@jobnums);
310   if ( $error ) {
311     $dbh->rollback if $oldAutoCommit;
312     return $error;
313   }
314
315   if ( $self->usergroup ) {
316     foreach my $groupname ( @{$self->usergroup} ) {
317       my $radius_usergroup = new FS::radius_usergroup ( {
318         svcnum    => $self->svcnum,
319         groupname => $groupname,
320       } );
321       my $error = $radius_usergroup->insert;
322       if ( $error ) {
323         $dbh->rollback if $oldAutoCommit;
324         return $error;
325       }
326     }
327   }
328
329   #false laziness with sub replace (and cust_main)
330   my $queue = new FS::queue {
331     'svcnum' => $self->svcnum,
332     'job'    => 'FS::svc_acct::append_fuzzyfiles'
333   };
334   $error = $queue->insert($self->username);
335   if ( $error ) {
336     $dbh->rollback if $oldAutoCommit;
337     return "queueing job (transaction rolled back): $error";
338   }
339
340   my $cust_pkg = $self->cust_svc->cust_pkg;
341
342   if ( $cust_pkg ) {
343     my $cust_main = $cust_pkg->cust_main;
344
345     if ( $conf->exists('emailinvoiceauto') ) {
346       my @invoicing_list = $cust_main->invoicing_list;
347       push @invoicing_list, $self->email;
348       $cust_main->invoicing_list(\@invoicing_list);
349     }
350
351     #welcome email
352     my $to = '';
353     if ( $welcome_template && $cust_pkg ) {
354       my $to = join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list );
355       if ( $to ) {
356         my $wqueue = new FS::queue {
357           'svcnum' => $self->svcnum,
358           'job'    => 'FS::svc_acct::send_email'
359         };
360         warn "attempting to queue email to $to";
361         my $error = $wqueue->insert(
362           'to'       => $to,
363           'from'     => $welcome_from,
364           'subject'  => $welcome_subject,
365           'mimetype' => $welcome_mimetype,
366           'body'     => $welcome_template->fill_in( HASH => {
367                           'username' => $self->username,
368                           'password' => $self->_password,
369                           'first'    => $cust_main->first,
370                           'last'     => $cust_main->getfield('last'),
371                           'pkg'      => $cust_pkg->part_pkg->pkg,
372                         } ),
373         );
374         if ( $error ) {
375           $dbh->rollback if $oldAutoCommit;
376           return "queuing welcome email: $error";
377         }
378
379         foreach my $jobnum ( @jobnums ) {
380           my $error = $wqueue->depend_insert($jobnum);
381           if ( $error ) {
382             $dbh->rollback if $oldAutoCommit;
383             return "queuing welcome email job dependancy: $error";
384           }
385         }
386
387       }
388
389     }
390
391   } # if ( $cust_pkg )
392
393   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
394   ''; #no error
395 }
396
397 =item delete
398
399 Deletes this account from the database.  If there is an error, returns the
400 error, otherwise returns false.
401
402 The corresponding FS::cust_svc record will be deleted as well.
403
404 (TODOC: new exports! $noexport_hack)
405
406 =cut
407
408 sub delete {
409   my $self = shift;
410
411   return "Can't delete an account which is a (svc_forward) source!"
412     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
413
414   return "Can't delete an account which is a (svc_forward) destination!"
415     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
416
417   return "Can't delete an account with (svc_www) web service!"
418     if qsearch( 'svc_www', { 'usersvc' => $self->usersvc } );
419
420   # what about records in session ? (they should refer to history table)
421
422   local $SIG{HUP} = 'IGNORE';
423   local $SIG{INT} = 'IGNORE';
424   local $SIG{QUIT} = 'IGNORE';
425   local $SIG{TERM} = 'IGNORE';
426   local $SIG{TSTP} = 'IGNORE';
427   local $SIG{PIPE} = 'IGNORE';
428
429   my $oldAutoCommit = $FS::UID::AutoCommit;
430   local $FS::UID::AutoCommit = 0;
431   my $dbh = dbh;
432
433   foreach my $cust_main_invoice (
434     qsearch( 'cust_main_invoice', { 'dest' => $self->svcnum } )
435   ) {
436     unless ( defined($cust_main_invoice) ) {
437       warn "WARNING: something's wrong with qsearch";
438       next;
439     }
440     my %hash = $cust_main_invoice->hash;
441     $hash{'dest'} = $self->email;
442     my $new = new FS::cust_main_invoice \%hash;
443     my $error = $new->replace($cust_main_invoice);
444     if ( $error ) {
445       $dbh->rollback if $oldAutoCommit;
446       return $error;
447     }
448   }
449
450   foreach my $svc_domain (
451     qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
452   ) {
453     my %hash = new FS::svc_domain->hash;
454     $hash{'catchall'} = '';
455     my $new = new FS::svc_domain \%hash;
456     my $error = $new->replace($svc_domain);
457     if ( $error ) {
458       $dbh->rollback if $oldAutoCommit;
459       return $error;
460     }
461   }
462
463   foreach my $radius_usergroup (
464     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
465   ) {
466     my $error = $radius_usergroup->delete;
467     if ( $error ) {
468       $dbh->rollback if $oldAutoCommit;
469       return $error;
470     }
471   }
472
473   my $error = $self->SUPER::delete;
474   if ( $error ) {
475     $dbh->rollback if $oldAutoCommit;
476     return $error;
477   }
478
479   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
480   '';
481 }
482
483 =item replace OLD_RECORD
484
485 Replaces OLD_RECORD with this one in the database.  If there is an error,
486 returns the error, otherwise returns false.
487
488 The additional field I<usergroup> can optionally be defined; if so it should
489 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
490 sqlradius export only)
491
492 =cut
493
494 sub replace {
495   my ( $new, $old ) = ( shift, shift );
496   my $error;
497
498   return "Username in use"
499     if $old->username ne $new->username &&
500       qsearchs( 'svc_acct', { 'username' => $new->username,
501                                'domsvc'   => $new->domsvc,
502                              } );
503   {
504     #no warnings 'numeric';  #alas, a 5.006-ism
505     local($^W) = 0;
506     return "Can't change uid!" if $old->uid != $new->uid;
507   }
508
509   #change homdir when we change username
510   $new->setfield('dir', '') if $old->username ne $new->username;
511
512   local $SIG{HUP} = 'IGNORE';
513   local $SIG{INT} = 'IGNORE';
514   local $SIG{QUIT} = 'IGNORE';
515   local $SIG{TERM} = 'IGNORE';
516   local $SIG{TSTP} = 'IGNORE';
517   local $SIG{PIPE} = 'IGNORE';
518
519   my $oldAutoCommit = $FS::UID::AutoCommit;
520   local $FS::UID::AutoCommit = 0;
521   my $dbh = dbh;
522
523   $old->usergroup( [ $old->radius_groups ] );
524   if ( $new->usergroup ) {
525     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
526     my @newgroups = @{$new->usergroup};
527     foreach my $oldgroup ( @{$old->usergroup} ) {
528       if ( grep { $oldgroup eq $_ } @newgroups ) {
529         @newgroups = grep { $oldgroup ne $_ } @newgroups;
530         next;
531       }
532       my $radius_usergroup = qsearchs('radius_usergroup', {
533         svcnum    => $old->svcnum,
534         groupname => $oldgroup,
535       } );
536       my $error = $radius_usergroup->delete;
537       if ( $error ) {
538         $dbh->rollback if $oldAutoCommit;
539         return "error deleting radius_usergroup $oldgroup: $error";
540       }
541     }
542
543     foreach my $newgroup ( @newgroups ) {
544       my $radius_usergroup = new FS::radius_usergroup ( {
545         svcnum    => $new->svcnum,
546         groupname => $newgroup,
547       } );
548       my $error = $radius_usergroup->insert;
549       if ( $error ) {
550         $dbh->rollback if $oldAutoCommit;
551         return "error adding radius_usergroup $newgroup: $error";
552       }
553     }
554
555   }
556
557   $error = $new->SUPER::replace($old);
558   if ( $error ) {
559     $dbh->rollback if $oldAutoCommit;
560     return $error if $error;
561   }
562
563   #false laziness with sub insert (and cust_main)
564   my $queue = new FS::queue {
565     'svcnum' => $new->svcnum,
566     'job'    => 'FS::svc_acct::append_fuzzyfiles'
567   };
568   $error = $queue->insert($new->username);
569   if ( $error ) {
570     $dbh->rollback if $oldAutoCommit;
571     return "queueing job (transaction rolled back): $error";
572   }
573
574
575   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
576   ''; #no error
577 }
578
579 =item suspend
580
581 Suspends this account by prefixing *SUSPENDED* to the password.  If there is an
582 error, returns the error, otherwise returns false.
583
584 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
585
586 =cut
587
588 sub suspend {
589   my $self = shift;
590   my %hash = $self->hash;
591   unless ( $hash{_password} =~ /^\*SUSPENDED\* /
592            || $hash{_password} eq '*'
593          ) {
594     $hash{_password} = '*SUSPENDED* '.$hash{_password};
595     my $new = new FS::svc_acct ( \%hash );
596     my $error = $new->replace($self);
597     return $error if $error;
598   }
599
600   $self->SUPER::suspend;
601 }
602
603 =item unsuspend
604
605 Unsuspends this account by removing *SUSPENDED* from the password.  If there is
606 an error, returns the error, otherwise returns false.
607
608 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
609
610 =cut
611
612 sub unsuspend {
613   my $self = shift;
614   my %hash = $self->hash;
615   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
616     $hash{_password} = $1;
617     my $new = new FS::svc_acct ( \%hash );
618     my $error = $new->replace($self);
619     return $error if $error;
620   }
621
622   $self->SUPER::unsuspend;
623 }
624
625 =item cancel
626
627 Just returns false (no error) for now.
628
629 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
630
631 =item check
632
633 Checks all fields to make sure this is a valid service.  If there is an error,
634 returns the error, otherwise returns false.  Called by the insert and replace
635 methods.
636
637 Sets any fixed values; see L<FS::part_svc>.
638
639 =cut
640
641 sub check {
642   my $self = shift;
643
644   my($recref) = $self->hashref;
645
646   my $x = $self->setfixed;
647   return $x unless ref($x);
648   my $part_svc = $x;
649
650   if ( $part_svc->part_svc_column('usergroup')->columnflag eq "F" ) {
651     $self->usergroup(
652       [ split(',', $part_svc->part_svc_column('usergroup')->columnvalue) ] );
653   }
654
655   my $error = $self->ut_numbern('svcnum')
656               || $self->ut_number('domsvc')
657               || $self->ut_textn('sec_phrase')
658   ;
659   return $error if $error;
660
661   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
662   if ( $username_uppercase ) {
663     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
664       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
665     $recref->{username} = $1;
666   } else {
667     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
668       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
669     $recref->{username} = $1;
670   }
671
672   if ( $username_letterfirst ) {
673     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
674   } elsif ( $username_letter ) {
675     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
676   }
677   if ( $username_noperiod ) {
678     $recref->{username} =~ /\./ and return gettext('illegal_username');
679   }
680   if ( $username_nounderscore ) {
681     $recref->{username} =~ /_/ and return gettext('illegal_username');
682   }
683   if ( $username_nodash ) {
684     $recref->{username} =~ /\-/ and return gettext('illegal_username');
685   }
686   unless ( $username_ampersand ) {
687     $recref->{username} =~ /\&/ and return gettext('illegal_username');
688   }
689
690   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
691   $recref->{popnum} = $1;
692   return "Unknown popnum" unless
693     ! $recref->{popnum} ||
694     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
695
696   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
697
698     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
699     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
700
701     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
702     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
703     #not all systems use gid=uid
704     #you can set a fixed gid in part_svc
705
706     return "Only root can have uid 0"
707       if $recref->{uid} == 0
708          && $recref->{username} ne 'root'
709          && $recref->{username} ne 'toor';
710
711
712     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
713       or return "Illegal directory: ". $recref->{dir};
714     $recref->{dir} = $1;
715     return "Illegal directory"
716       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
717     return "Illegal directory"
718       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
719     unless ( $recref->{dir} ) {
720       $recref->{dir} = $dir_prefix . '/';
721       if ( $dirhash > 0 ) {
722         for my $h ( 1 .. $dirhash ) {
723           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
724         }
725       } elsif ( $dirhash < 0 ) {
726         for my $h ( reverse $dirhash .. -1 ) {
727           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
728         }
729       }
730       $recref->{dir} .= $recref->{username};
731     ;
732     }
733
734     unless ( $recref->{username} eq 'sync' ) {
735       if ( grep $_ eq $recref->{shell}, @shells ) {
736         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
737       } else {
738         return "Illegal shell \`". $self->shell. "\'; ".
739                $conf->dir. "/shells contains: @shells";
740       }
741     } else {
742       $recref->{shell} = '/bin/sync';
743     }
744
745   } else {
746     $recref->{gid} ne '' ? 
747       return "Can't have gid without uid" : ( $recref->{gid}='' );
748     $recref->{dir} ne '' ? 
749       return "Can't have directory without uid" : ( $recref->{dir}='' );
750     $recref->{shell} ne '' ? 
751       return "Can't have shell without uid" : ( $recref->{shell}='' );
752   }
753
754   #  $error = $self->ut_textn('finger');
755   #  return $error if $error;
756   $self->getfield('finger') =~
757     /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
758       or return "Illegal finger: ". $self->getfield('finger');
759   $self->setfield('finger', $1);
760
761   $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota";
762   $recref->{quota} = $1;
763
764   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
765     unless ( $recref->{slipip} eq '0e0' ) {
766       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
767         or return "Illegal slipip: ". $self->slipip;
768       $recref->{slipip} = $1;
769     } else {
770       $recref->{slipip} = '0e0';
771     }
772
773   }
774
775   #arbitrary RADIUS stuff; allow ut_textn for now
776   foreach ( grep /^radius_/, fields('svc_acct') ) {
777     $self->ut_textn($_);
778   }
779
780   #generate a password if it is blank
781   $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
782     unless ( $recref->{_password} );
783
784   #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
785   if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
786     $recref->{_password} = $1.$3;
787     #uncomment this to encrypt password immediately upon entry, or run
788     #bin/crypt_pw in cron to give new users a window during which their
789     #password is available to techs, for faxing, etc.  (also be aware of 
790     #radius issues!)
791     #$recref->{password} = $1.
792     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
793     #;
794   } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;\+]{13,34})$/ ) {
795     $recref->{_password} = $1.$3;
796   } elsif ( $recref->{_password} eq '*' ) {
797     $recref->{_password} = '*';
798   } elsif ( $recref->{_password} eq '!!' ) {
799     $recref->{_password} = '!!';
800   } else {
801     #return "Illegal password";
802     return gettext('illegal_password'). " $passwordmin-$passwordmax ".
803            FS::Msgcat::_gettext('illegal_password_characters').
804            ": ". $recref->{_password};
805   }
806
807   ''; #no error
808 }
809
810 =item radius
811
812 Depriciated, use radius_reply instead.
813
814 =cut
815
816 sub radius {
817   carp "FS::svc_acct::radius depriciated, use radius_reply";
818   $_[0]->radius_reply;
819 }
820
821 =item radius_reply
822
823 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
824 reply attributes of this record.
825
826 Note that this is now the preferred method for reading RADIUS attributes - 
827 accessing the columns directly is discouraged, as the column names are
828 expected to change in the future.
829
830 =cut
831
832 sub radius_reply { 
833   my $self = shift;
834   my %reply =
835     map {
836       /^(radius_(.*))$/;
837       my($column, $attrib) = ($1, $2);
838       #$attrib =~ s/_/\-/g;
839       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
840     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
841   if ( $self->slipip && $self->slipip ne '0e0' ) {
842     $reply{'Framed-IP-Address'} = $self->slipip;
843   }
844   %reply;
845 }
846
847 =item radius_check
848
849 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
850 check attributes of this record.
851
852 Note that this is now the preferred method for reading RADIUS attributes - 
853 accessing the columns directly is discouraged, as the column names are
854 expected to change in the future.
855
856 =cut
857
858 sub radius_check {
859   my $self = shift;
860   ( 'Password' => $self->_password,
861     map {
862       /^(rc_(.*))$/;
863       my($column, $attrib) = ($1, $2);
864       #$attrib =~ s/_/\-/g;
865       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
866     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table )
867   );
868 }
869
870 =item domain
871
872 Returns the domain associated with this account.
873
874 =cut
875
876 sub domain {
877   my $self = shift;
878   die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
879   my $svc_domain = $self->svc_domain
880     or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
881   $svc_domain->domain;
882 }
883
884 =item svc_domain
885
886 Returns the FS::svc_domain record for this account's domain (see
887 L<FS::svc_domain>).
888
889 =cut
890
891 sub svc_domain {
892   my $self = shift;
893   $self->{'_domsvc'}
894     ? $self->{'_domsvc'}
895     : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
896 }
897
898 =item cust_svc
899
900 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
901
902 sub cust_svc {
903   my $self = shift;
904   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
905 }
906
907 =item email
908
909 Returns an email address associated with the account.
910
911 =cut
912
913 sub email {
914   my $self = shift;
915   $self->username. '@'. $self->domain;
916 }
917
918 =item seconds_since TIMESTAMP
919
920 Returns the number of seconds this account has been online since TIMESTAMP.
921 See L<FS::session>
922
923 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
924 L<Time::Local> and L<Date::Parse> for conversion functions.
925
926 =cut
927
928 #note: POD here, implementation in FS::cust_svc
929 sub seconds_since {
930   my $self = shift;
931   $self->cust_svc->seconds_since(@_);
932 }
933
934 =item radius_groups
935
936 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
937
938 =cut
939
940 sub radius_groups {
941   my $self = shift;
942   if ( $self->usergroup ) {
943     #when provisioning records, export callback runs in svc_Common.pm before
944     #radius_usergroup records can be inserted...
945     @{$self->usergroup};
946   } else {
947     map { $_->groupname }
948       qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
949   }
950 }
951
952 =back
953
954 =head1 SUBROUTINES
955
956 =over 4
957
958 =item send_email
959
960 =cut
961
962 sub send_email {
963   my %opt = @_;
964
965   use Date::Format;
966   use Mail::Internet 1.44;
967   use Mail::Header;
968
969   $opt{mimetype} ||= 'text/plain';
970   $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
971
972   $ENV{MAILADDRESS} = $opt{from};
973   my $header = new Mail::Header ( [
974     "From: $opt{from}",
975     "To: $opt{to}",
976     "Sender: $opt{from}",
977     "Reply-To: $opt{from}",
978     "Date: ". time2str("%a, %d %b %Y %X %z", time),
979     "Subject: $opt{subject}",
980     "Content-Type: $opt{mimetype}",
981   ] );
982   my $message = new Mail::Internet (
983     'Header' => $header,
984     'Body' => [ map "$_\n", split("\n", $opt{body}) ],
985   );
986   $!=0;
987   $message->smtpsend( Host => $smtpmachine )
988     or $message->smtpsend( Host => $smtpmachine, Debug => 1 )
989       or die "can't send email to $opt{to} via $smtpmachine with SMTP: $!";
990 }
991
992 =item check_and_rebuild_fuzzyfiles
993
994 =cut
995
996 sub check_and_rebuild_fuzzyfiles {
997   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
998   -e "$dir/svc_acct.username"
999     or &rebuild_fuzzyfiles;
1000 }
1001
1002 =item rebuild_fuzzyfiles
1003
1004 =cut
1005
1006 sub rebuild_fuzzyfiles {
1007
1008   use Fcntl qw(:flock);
1009
1010   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1011
1012   #username
1013
1014   open(USERNAMELOCK,">>$dir/svc_acct.username")
1015     or die "can't open $dir/svc_acct.username: $!";
1016   flock(USERNAMELOCK,LOCK_EX)
1017     or die "can't lock $dir/svc_acct.username: $!";
1018
1019   my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
1020
1021   open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
1022     or die "can't open $dir/svc_acct.username.tmp: $!";
1023   print USERNAMECACHE join("\n", @all_username), "\n";
1024   close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
1025
1026   rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
1027   close USERNAMELOCK;
1028
1029 }
1030
1031 =item all_username
1032
1033 =cut
1034
1035 sub all_username {
1036   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1037   open(USERNAMECACHE,"<$dir/svc_acct.username")
1038     or die "can't open $dir/svc_acct.username: $!";
1039   my @array = map { chomp; $_; } <USERNAMECACHE>;
1040   close USERNAMECACHE;
1041   \@array;
1042 }
1043
1044 =item append_fuzzyfiles USERNAME
1045
1046 =cut
1047
1048 sub append_fuzzyfiles {
1049   my $username = shift;
1050
1051   &check_and_rebuild_fuzzyfiles;
1052
1053   use Fcntl qw(:flock);
1054
1055   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1056
1057   open(USERNAME,">>$dir/svc_acct.username")
1058     or die "can't open $dir/svc_acct.username: $!";
1059   flock(USERNAME,LOCK_EX)
1060     or die "can't lock $dir/svc_acct.username: $!";
1061
1062   print USERNAME "$username\n";
1063
1064   flock(USERNAME,LOCK_UN)
1065     or die "can't unlock $dir/svc_acct.username: $!";
1066   close USERNAME;
1067
1068   1;
1069 }
1070
1071
1072
1073 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
1074
1075 =cut
1076
1077 sub radius_usergroup_selector {
1078   my $sel_groups = shift;
1079   my %sel_groups = map { $_=>1 } @$sel_groups;
1080
1081   my $selectname = shift || 'radius_usergroup';
1082
1083   my $dbh = dbh;
1084   my $sth = $dbh->prepare(
1085     'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
1086   ) or die $dbh->errstr;
1087   $sth->execute() or die $sth->errstr;
1088   my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
1089
1090   my $html = <<END;
1091     <SCRIPT>
1092     function ${selectname}_doadd(object) {
1093       var myvalue = object.${selectname}_add.value;
1094       var optionName = new Option(myvalue,myvalue,false,true);
1095       var length = object.$selectname.length;
1096       object.$selectname.options[length] = optionName;
1097       object.${selectname}_add.value = "";
1098     }
1099     </SCRIPT>
1100     <SELECT MULTIPLE NAME="$selectname">
1101 END
1102
1103   foreach my $group ( @all_groups ) {
1104     $html .= '<OPTION';
1105     if ( $sel_groups{$group} ) {
1106       $html .= ' SELECTED';
1107       $sel_groups{$group} = 0;
1108     }
1109     $html .= ">$group</OPTION>\n";
1110   }
1111   foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
1112     $html .= "<OPTION SELECTED>$group</OPTION>\n";
1113   };
1114   $html .= '</SELECT>';
1115
1116   $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
1117            qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
1118
1119   $html;
1120 }
1121
1122 =back
1123
1124 =head1 BUGS
1125
1126 The $recref stuff in sub check should be cleaned up.
1127
1128 The suspend, unsuspend and cancel methods update the database, but not the
1129 current object.  This is probably a bug as it's unexpected and
1130 counterintuitive.
1131
1132 radius_usergroup_selector?  putting web ui components in here?  they should
1133 probably live somewhere else...
1134
1135 =head1 SEE ALSO
1136
1137 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
1138 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
1139 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
1140 L<freeside-queued>), L<FS::svc_acct_pop>,
1141 schema.html from the base documentation.
1142
1143 =cut
1144
1145 1;
1146