remove domain config file, closes: Bug#269
[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   my $cust_main = $cust_pkg->cust_main;
342
343   my $cust_pkg = $self->cust_svc->cust_pkg;
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   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
392   ''; #no error
393 }
394
395 =item delete
396
397 Deletes this account from the database.  If there is an error, returns the
398 error, otherwise returns false.
399
400 The corresponding FS::cust_svc record will be deleted as well.
401
402 (TODOC: new exports! $noexport_hack)
403
404 =cut
405
406 sub delete {
407   my $self = shift;
408
409   return "Can't delete an account which is a (svc_forward) source!"
410     if qsearch( 'svc_forward', { 'srcsvc' => $self->svcnum } );
411
412   return "Can't delete an account which is a (svc_forward) destination!"
413     if qsearch( 'svc_forward', { 'dstsvc' => $self->svcnum } );
414
415   return "Can't delete an account with (svc_www) web service!"
416     if qsearch( 'svc_www', { 'usersvc' => $self->usersvc } );
417
418   # what about records in session ? (they should refer to history table)
419
420   local $SIG{HUP} = 'IGNORE';
421   local $SIG{INT} = 'IGNORE';
422   local $SIG{QUIT} = 'IGNORE';
423   local $SIG{TERM} = 'IGNORE';
424   local $SIG{TSTP} = 'IGNORE';
425   local $SIG{PIPE} = 'IGNORE';
426
427   my $oldAutoCommit = $FS::UID::AutoCommit;
428   local $FS::UID::AutoCommit = 0;
429   my $dbh = dbh;
430
431   foreach my $cust_main_invoice (
432     qsearch( 'cust_main_invoice', { 'dest' => $self->svcnum } )
433   ) {
434     unless ( defined($cust_main_invoice) ) {
435       warn "WARNING: something's wrong with qsearch";
436       next;
437     }
438     my %hash = $cust_main_invoice->hash;
439     $hash{'dest'} = $self->email;
440     my $new = new FS::cust_main_invoice \%hash;
441     my $error = $new->replace($cust_main_invoice);
442     if ( $error ) {
443       $dbh->rollback if $oldAutoCommit;
444       return $error;
445     }
446   }
447
448   foreach my $svc_domain (
449     qsearch( 'svc_domain', { 'catchall' => $self->svcnum } )
450   ) {
451     my %hash = new FS::svc_domain->hash;
452     $hash{'catchall'} = '';
453     my $new = new FS::svc_domain \%hash;
454     my $error = $new->replace($svc_domain);
455     if ( $error ) {
456       $dbh->rollback if $oldAutoCommit;
457       return $error;
458     }
459   }
460
461   foreach my $radius_usergroup (
462     qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } )
463   ) {
464     my $error = $radius_usergroup->delete;
465     if ( $error ) {
466       $dbh->rollback if $oldAutoCommit;
467       return $error;
468     }
469   }
470
471   my $error = $self->SUPER::delete;
472   if ( $error ) {
473     $dbh->rollback if $oldAutoCommit;
474     return $error;
475   }
476
477   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
478   '';
479 }
480
481 =item replace OLD_RECORD
482
483 Replaces OLD_RECORD with this one in the database.  If there is an error,
484 returns the error, otherwise returns false.
485
486 The additional field I<usergroup> can optionally be defined; if so it should
487 contain an arrayref of group names.  See L<FS::radius_usergroup>.  (used in
488 sqlradius export only)
489
490 =cut
491
492 sub replace {
493   my ( $new, $old ) = ( shift, shift );
494   my $error;
495
496   return "Username in use"
497     if $old->username ne $new->username &&
498       qsearchs( 'svc_acct', { 'username' => $new->username,
499                                'domsvc'   => $new->domsvc,
500                              } );
501   {
502     #no warnings 'numeric';  #alas, a 5.006-ism
503     local($^W) = 0;
504     return "Can't change uid!" if $old->uid != $new->uid;
505   }
506
507   #change homdir when we change username
508   $new->setfield('dir', '') if $old->username ne $new->username;
509
510   local $SIG{HUP} = 'IGNORE';
511   local $SIG{INT} = 'IGNORE';
512   local $SIG{QUIT} = 'IGNORE';
513   local $SIG{TERM} = 'IGNORE';
514   local $SIG{TSTP} = 'IGNORE';
515   local $SIG{PIPE} = 'IGNORE';
516
517   my $oldAutoCommit = $FS::UID::AutoCommit;
518   local $FS::UID::AutoCommit = 0;
519   my $dbh = dbh;
520
521   $old->usergroup( [ $old->radius_groups ] );
522   if ( $new->usergroup ) {
523     #(sorta) false laziness with FS::part_export::sqlradius::_export_replace
524     my @newgroups = @{$new->usergroup};
525     foreach my $oldgroup ( @{$old->usergroup} ) {
526       if ( grep { $oldgroup eq $_ } @newgroups ) {
527         @newgroups = grep { $oldgroup ne $_ } @newgroups;
528         next;
529       }
530       my $radius_usergroup = qsearchs('radius_usergroup', {
531         svcnum    => $old->svcnum,
532         groupname => $oldgroup,
533       } );
534       my $error = $radius_usergroup->delete;
535       if ( $error ) {
536         $dbh->rollback if $oldAutoCommit;
537         return "error deleting radius_usergroup $oldgroup: $error";
538       }
539     }
540
541     foreach my $newgroup ( @newgroups ) {
542       my $radius_usergroup = new FS::radius_usergroup ( {
543         svcnum    => $new->svcnum,
544         groupname => $newgroup,
545       } );
546       my $error = $radius_usergroup->insert;
547       if ( $error ) {
548         $dbh->rollback if $oldAutoCommit;
549         return "error adding radius_usergroup $newgroup: $error";
550       }
551     }
552
553   }
554
555   $error = $new->SUPER::replace($old);
556   if ( $error ) {
557     $dbh->rollback if $oldAutoCommit;
558     return $error if $error;
559   }
560
561   #false laziness with sub insert (and cust_main)
562   my $queue = new FS::queue {
563     'svcnum' => $new->svcnum,
564     'job'    => 'FS::svc_acct::append_fuzzyfiles'
565   };
566   $error = $queue->insert($new->username);
567   if ( $error ) {
568     $dbh->rollback if $oldAutoCommit;
569     return "queueing job (transaction rolled back): $error";
570   }
571
572
573   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
574   ''; #no error
575 }
576
577 =item suspend
578
579 Suspends this account by prefixing *SUSPENDED* to the password.  If there is an
580 error, returns the error, otherwise returns false.
581
582 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
583
584 =cut
585
586 sub suspend {
587   my $self = shift;
588   my %hash = $self->hash;
589   unless ( $hash{_password} =~ /^\*SUSPENDED\* /
590            || $hash{_password} eq '*'
591          ) {
592     $hash{_password} = '*SUSPENDED* '.$hash{_password};
593     my $new = new FS::svc_acct ( \%hash );
594     my $error = $new->replace($self);
595     return $error if $error;
596   }
597
598   $self->SUPER::suspend;
599 }
600
601 =item unsuspend
602
603 Unsuspends this account by removing *SUSPENDED* from the password.  If there is
604 an error, returns the error, otherwise returns false.
605
606 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
607
608 =cut
609
610 sub unsuspend {
611   my $self = shift;
612   my %hash = $self->hash;
613   if ( $hash{_password} =~ /^\*SUSPENDED\* (.*)$/ ) {
614     $hash{_password} = $1;
615     my $new = new FS::svc_acct ( \%hash );
616     my $error = $new->replace($self);
617     return $error if $error;
618   }
619
620   $self->SUPER::unsuspend;
621 }
622
623 =item cancel
624
625 Just returns false (no error) for now.
626
627 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
628
629 =item check
630
631 Checks all fields to make sure this is a valid service.  If there is an error,
632 returns the error, otherwise returns false.  Called by the insert and replace
633 methods.
634
635 Sets any fixed values; see L<FS::part_svc>.
636
637 =cut
638
639 sub check {
640   my $self = shift;
641
642   my($recref) = $self->hashref;
643
644   my $x = $self->setfixed;
645   return $x unless ref($x);
646   my $part_svc = $x;
647
648   if ( $part_svc->part_svc_column('usergroup')->columnflag eq "F" ) {
649     $self->usergroup(
650       [ split(',', $part_svc->part_svc_column('usergroup')->columnvalue) ] );
651   }
652
653   my $error = $self->ut_numbern('svcnum')
654               || $self->ut_number('domsvc')
655               || $self->ut_textn('sec_phrase')
656   ;
657   return $error if $error;
658
659   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
660   if ( $username_uppercase ) {
661     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
662       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
663     $recref->{username} = $1;
664   } else {
665     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
666       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
667     $recref->{username} = $1;
668   }
669
670   if ( $username_letterfirst ) {
671     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
672   } elsif ( $username_letter ) {
673     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
674   }
675   if ( $username_noperiod ) {
676     $recref->{username} =~ /\./ and return gettext('illegal_username');
677   }
678   if ( $username_nounderscore ) {
679     $recref->{username} =~ /_/ and return gettext('illegal_username');
680   }
681   if ( $username_nodash ) {
682     $recref->{username} =~ /\-/ and return gettext('illegal_username');
683   }
684   unless ( $username_ampersand ) {
685     $recref->{username} =~ /\&/ and return gettext('illegal_username');
686   }
687
688   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
689   $recref->{popnum} = $1;
690   return "Unknown popnum" unless
691     ! $recref->{popnum} ||
692     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
693
694   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
695
696     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
697     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
698
699     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
700     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
701     #not all systems use gid=uid
702     #you can set a fixed gid in part_svc
703
704     return "Only root can have uid 0"
705       if $recref->{uid} == 0
706          && $recref->{username} ne 'root'
707          && $recref->{username} ne 'toor';
708
709
710     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
711       or return "Illegal directory: ". $recref->{dir};
712     $recref->{dir} = $1;
713     return "Illegal directory"
714       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
715     return "Illegal directory"
716       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
717     unless ( $recref->{dir} ) {
718       $recref->{dir} = $dir_prefix . '/';
719       if ( $dirhash > 0 ) {
720         for my $h ( 1 .. $dirhash ) {
721           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
722         }
723       } elsif ( $dirhash < 0 ) {
724         for my $h ( reverse $dirhash .. -1 ) {
725           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
726         }
727       }
728       $recref->{dir} .= $recref->{username};
729     ;
730     }
731
732     unless ( $recref->{username} eq 'sync' ) {
733       if ( grep $_ eq $recref->{shell}, @shells ) {
734         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
735       } else {
736         return "Illegal shell \`". $self->shell. "\'; ".
737                $conf->dir. "/shells contains: @shells";
738       }
739     } else {
740       $recref->{shell} = '/bin/sync';
741     }
742
743   } else {
744     $recref->{gid} ne '' ? 
745       return "Can't have gid without uid" : ( $recref->{gid}='' );
746     $recref->{dir} ne '' ? 
747       return "Can't have directory without uid" : ( $recref->{dir}='' );
748     $recref->{shell} ne '' ? 
749       return "Can't have shell without uid" : ( $recref->{shell}='' );
750   }
751
752   #  $error = $self->ut_textn('finger');
753   #  return $error if $error;
754   $self->getfield('finger') =~
755     /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
756       or return "Illegal finger: ". $self->getfield('finger');
757   $self->setfield('finger', $1);
758
759   $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota";
760   $recref->{quota} = $1;
761
762   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
763     unless ( $recref->{slipip} eq '0e0' ) {
764       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
765         or return "Illegal slipip". $self->slipip;
766       $recref->{slipip} = $1;
767     } else {
768       $recref->{slipip} = '0e0';
769     }
770
771   }
772
773   #arbitrary RADIUS stuff; allow ut_textn for now
774   foreach ( grep /^radius_/, fields('svc_acct') ) {
775     $self->ut_textn($_);
776   }
777
778   #generate a password if it is blank
779   $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
780     unless ( $recref->{_password} );
781
782   #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
783   if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
784     $recref->{_password} = $1.$3;
785     #uncomment this to encrypt password immediately upon entry, or run
786     #bin/crypt_pw in cron to give new users a window during which their
787     #password is available to techs, for faxing, etc.  (also be aware of 
788     #radius issues!)
789     #$recref->{password} = $1.
790     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
791     #;
792   } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;]{13,34})$/ ) {
793     $recref->{_password} = $1.$3;
794   } elsif ( $recref->{_password} eq '*' ) {
795     $recref->{_password} = '*';
796   } elsif ( $recref->{_password} eq '!!' ) {
797     $recref->{_password} = '!!';
798   } else {
799     #return "Illegal password";
800     return gettext('illegal_password'). " $passwordmin-$passwordmax ".
801            FS::Msgcat::_gettext('illegal_password_characters').
802            ": ". $recref->{_password};
803   }
804
805   ''; #no error
806 }
807
808 =item radius
809
810 Depriciated, use radius_reply instead.
811
812 =cut
813
814 sub radius {
815   carp "FS::svc_acct::radius depriciated, use radius_reply";
816   $_[0]->radius_reply;
817 }
818
819 =item radius_reply
820
821 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
822 reply attributes of this record.
823
824 Note that this is now the preferred method for reading RADIUS attributes - 
825 accessing the columns directly is discouraged, as the column names are
826 expected to change in the future.
827
828 =cut
829
830 sub radius_reply { 
831   my $self = shift;
832   my %reply =
833     map {
834       /^(radius_(.*))$/;
835       my($column, $attrib) = ($1, $2);
836       #$attrib =~ s/_/\-/g;
837       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
838     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
839   if ( $self->slipip && $self->slipip ne '0e0' ) {
840     $reply{'Framed-IP-Address'} = $self->slipip;
841   }
842   %reply;
843 }
844
845 =item radius_check
846
847 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
848 check attributes of this record.
849
850 Note that this is now the preferred method for reading RADIUS attributes - 
851 accessing the columns directly is discouraged, as the column names are
852 expected to change in the future.
853
854 =cut
855
856 sub radius_check {
857   my $self = shift;
858   ( 'Password' => $self->_password,
859     map {
860       /^(rc_(.*))$/;
861       my($column, $attrib) = ($1, $2);
862       #$attrib =~ s/_/\-/g;
863       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
864     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table )
865   );
866 }
867
868 =item domain
869
870 Returns the domain associated with this account.
871
872 =cut
873
874 sub domain {
875   my $self = shift;
876   die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
877   my $svc_domain = $self->svc_domain
878     or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
879   $svc_domain->domain;
880 }
881
882 =item svc_domain
883
884 Returns the FS::svc_domain record for this account's domain (see
885 L<FS::svc_domain>).
886
887 =cut
888
889 sub svc_domain {
890   my $self = shift;
891   $self->{'_domsvc'}
892     ? $self->{'_domsvc'}
893     : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
894 }
895
896 =item cust_svc
897
898 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
899
900 sub cust_svc {
901   my $self = shift;
902   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
903 }
904
905 =item email
906
907 Returns an email address associated with the account.
908
909 =cut
910
911 sub email {
912   my $self = shift;
913   $self->username. '@'. $self->domain;
914 }
915
916 =item seconds_since TIMESTAMP
917
918 Returns the number of seconds this account has been online since TIMESTAMP.
919 See L<FS::session>
920
921 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
922 L<Time::Local> and L<Date::Parse> for conversion functions.
923
924 =cut
925
926 #note: POD here, implementation in FS::cust_svc
927 sub seconds_since {
928   my $self = shift;
929   $self->cust_svc->seconds_since(@_);
930 }
931
932 =item radius_groups
933
934 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
935
936 =cut
937
938 sub radius_groups {
939   my $self = shift;
940   if ( $self->usergroup ) {
941     #when provisioning records, export callback runs in svc_Common.pm before
942     #radius_usergroup records can be inserted...
943     @{$self->usergroup};
944   } else {
945     map { $_->groupname }
946       qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
947   }
948 }
949
950 =back
951
952 =head1 SUBROUTINES
953
954 =over 4
955
956 =item send_email
957
958 =cut
959
960 sub send_email {
961   my %opt = @_;
962
963   use Date::Format;
964   use Mail::Internet 1.44;
965   use Mail::Header;
966
967   $opt{mimetype} ||= 'text/plain';
968   $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
969
970   $ENV{MAILADDRESS} = $opt{from};
971   my $header = new Mail::Header ( [
972     "From: $opt{from}",
973     "To: $opt{to}",
974     "Sender: $opt{from}",
975     "Reply-To: $opt{from}",
976     "Date: ". time2str("%a, %d %b %Y %X %z", time),
977     "Subject: $opt{subject}",
978     "Content-Type: $opt{mimetype}",
979   ] );
980   my $message = new Mail::Internet (
981     'Header' => $header,
982     'Body' => [ map "$_\n", split("\n", $opt{body}) ],
983   );
984   $!=0;
985   $message->smtpsend( Host => $smtpmachine )
986     or $message->smtpsend( Host => $smtpmachine, Debug => 1 )
987       or die "can't send email to $opt{to} via $smtpmachine with SMTP: $!";
988 }
989
990 =item check_and_rebuild_fuzzyfiles
991
992 =cut
993
994 sub check_and_rebuild_fuzzyfiles {
995   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
996   -e "$dir/svc_acct.username"
997     or &rebuild_fuzzyfiles;
998 }
999
1000 =item rebuild_fuzzyfiles
1001
1002 =cut
1003
1004 sub rebuild_fuzzyfiles {
1005
1006   use Fcntl qw(:flock);
1007
1008   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1009
1010   #username
1011
1012   open(USERNAMELOCK,">>$dir/svc_acct.username")
1013     or die "can't open $dir/svc_acct.username: $!";
1014   flock(USERNAMELOCK,LOCK_EX)
1015     or die "can't lock $dir/svc_acct.username: $!";
1016
1017   my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
1018
1019   open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
1020     or die "can't open $dir/svc_acct.username.tmp: $!";
1021   print USERNAMECACHE join("\n", @all_username), "\n";
1022   close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
1023
1024   rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
1025   close USERNAMELOCK;
1026
1027 }
1028
1029 =item all_username
1030
1031 =cut
1032
1033 sub all_username {
1034   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1035   open(USERNAMECACHE,"<$dir/svc_acct.username")
1036     or die "can't open $dir/svc_acct.username: $!";
1037   my @array = map { chomp; $_; } <USERNAMECACHE>;
1038   close USERNAMECACHE;
1039   \@array;
1040 }
1041
1042 =item append_fuzzyfiles USERNAME
1043
1044 =cut
1045
1046 sub append_fuzzyfiles {
1047   my $username = shift;
1048
1049   &check_and_rebuild_fuzzyfiles;
1050
1051   use Fcntl qw(:flock);
1052
1053   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1054
1055   open(USERNAME,">>$dir/svc_acct.username")
1056     or die "can't open $dir/svc_acct.username: $!";
1057   flock(USERNAME,LOCK_EX)
1058     or die "can't lock $dir/svc_acct.username: $!";
1059
1060   print USERNAME "$username\n";
1061
1062   flock(USERNAME,LOCK_UN)
1063     or die "can't unlock $dir/svc_acct.username: $!";
1064   close USERNAME;
1065
1066   1;
1067 }
1068
1069
1070
1071 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
1072
1073 =cut
1074
1075 sub radius_usergroup_selector {
1076   my $sel_groups = shift;
1077   my %sel_groups = map { $_=>1 } @$sel_groups;
1078
1079   my $selectname = shift || 'radius_usergroup';
1080
1081   my $dbh = dbh;
1082   my $sth = $dbh->prepare(
1083     'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
1084   ) or die $dbh->errstr;
1085   $sth->execute() or die $sth->errstr;
1086   my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
1087
1088   my $html = <<END;
1089     <SCRIPT>
1090     function ${selectname}_doadd(object) {
1091       var myvalue = object.${selectname}_add.value;
1092       var optionName = new Option(myvalue,myvalue,false,true);
1093       var length = object.$selectname.length;
1094       object.$selectname.options[length] = optionName;
1095       object.${selectname}_add.value = "";
1096     }
1097     </SCRIPT>
1098     <SELECT MULTIPLE NAME="$selectname">
1099 END
1100
1101   foreach my $group ( @all_groups ) {
1102     $html .= '<OPTION';
1103     if ( $sel_groups{$group} ) {
1104       $html .= ' SELECTED';
1105       $sel_groups{$group} = 0;
1106     }
1107     $html .= ">$group</OPTION>\n";
1108   }
1109   foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
1110     $html .= "<OPTION SELECTED>$group</OPTION>\n";
1111   };
1112   $html .= '</SELECT>';
1113
1114   $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
1115            qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
1116
1117   $html;
1118 }
1119
1120 =back
1121
1122 =head1 BUGS
1123
1124 The $recref stuff in sub check should be cleaned up.
1125
1126 The suspend, unsuspend and cancel methods update the database, but not the
1127 current object.  This is probably a bug as it's unexpected and
1128 counterintuitive.
1129
1130 radius_usergroup_selector?  putting web ui components in here?  they should
1131 probably live somewhere else...
1132
1133 =head1 SEE ALSO
1134
1135 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
1136 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
1137 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
1138 L<freeside-queued>), L<FS::svc_acct_pop>,
1139 schema.html from the base documentation.
1140
1141 =cut
1142
1143 1;
1144