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