Generic virtual field support
[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_foreign_key('domsvc', 'svc_domain', 'svcnum' )
678               || $self->ut_textn('sec_phrase')
679   ;
680   return $error if $error;
681
682   my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
683   if ( $username_uppercase ) {
684     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/i
685       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
686     $recref->{username} = $1;
687   } else {
688     $recref->{username} =~ /^([a-z0-9_\-\.\&]{$usernamemin,$ulen})$/
689       or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
690     $recref->{username} = $1;
691   }
692
693   if ( $username_letterfirst ) {
694     $recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
695   } elsif ( $username_letter ) {
696     $recref->{username} =~ /[a-z]/ or return gettext('illegal_username');
697   }
698   if ( $username_noperiod ) {
699     $recref->{username} =~ /\./ and return gettext('illegal_username');
700   }
701   if ( $username_nounderscore ) {
702     $recref->{username} =~ /_/ and return gettext('illegal_username');
703   }
704   if ( $username_nodash ) {
705     $recref->{username} =~ /\-/ and return gettext('illegal_username');
706   }
707   unless ( $username_ampersand ) {
708     $recref->{username} =~ /\&/ and return gettext('illegal_username');
709   }
710
711   $recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
712   $recref->{popnum} = $1;
713   return "Unknown popnum" unless
714     ! $recref->{popnum} ||
715     qsearchs('svc_acct_pop',{'popnum'=> $recref->{popnum} } );
716
717   unless ( $part_svc->part_svc_column('uid')->columnflag eq 'F' ) {
718
719     $recref->{uid} =~ /^(\d*)$/ or return "Illegal uid";
720     $recref->{uid} = $1 eq '' ? $self->unique('uid') : $1;
721
722     $recref->{gid} =~ /^(\d*)$/ or return "Illegal gid";
723     $recref->{gid} = $1 eq '' ? $recref->{uid} : $1;
724     #not all systems use gid=uid
725     #you can set a fixed gid in part_svc
726
727     return "Only root can have uid 0"
728       if $recref->{uid} == 0
729          && $recref->{username} ne 'root'
730          && $recref->{username} ne 'toor';
731
732
733     $recref->{dir} =~ /^([\/\w\-\.\&]*)$/
734       or return "Illegal directory: ". $recref->{dir};
735     $recref->{dir} = $1;
736     return "Illegal directory"
737       if $recref->{dir} =~ /(^|\/)\.+(\/|$)/; #no .. component
738     return "Illegal directory"
739       if $recref->{dir} =~ /\&/ && ! $username_ampersand;
740     unless ( $recref->{dir} ) {
741       $recref->{dir} = $dir_prefix . '/';
742       if ( $dirhash > 0 ) {
743         for my $h ( 1 .. $dirhash ) {
744           $recref->{dir} .= substr($recref->{username}, $h-1, 1). '/';
745         }
746       } elsif ( $dirhash < 0 ) {
747         for my $h ( reverse $dirhash .. -1 ) {
748           $recref->{dir} .= substr($recref->{username}, $h, 1). '/';
749         }
750       }
751       $recref->{dir} .= $recref->{username};
752     ;
753     }
754
755     unless ( $recref->{username} eq 'sync' ) {
756       if ( grep $_ eq $recref->{shell}, @shells ) {
757         $recref->{shell} = (grep $_ eq $recref->{shell}, @shells)[0];
758       } else {
759         return "Illegal shell \`". $self->shell. "\'; ".
760                $conf->dir. "/shells contains: @shells";
761       }
762     } else {
763       $recref->{shell} = '/bin/sync';
764     }
765
766   } else {
767     $recref->{gid} ne '' ? 
768       return "Can't have gid without uid" : ( $recref->{gid}='' );
769     $recref->{dir} ne '' ? 
770       return "Can't have directory without uid" : ( $recref->{dir}='' );
771     $recref->{shell} ne '' ? 
772       return "Can't have shell without uid" : ( $recref->{shell}='' );
773   }
774
775   #  $error = $self->ut_textn('finger');
776   #  return $error if $error;
777   $self->getfield('finger') =~
778     /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
779       or return "Illegal finger: ". $self->getfield('finger');
780   $self->setfield('finger', $1);
781
782   $recref->{quota} =~ /^(\d*)$/ or return "Illegal quota";
783   $recref->{quota} = $1;
784
785   unless ( $part_svc->part_svc_column('slipip')->columnflag eq 'F' ) {
786     if ( $recref->{slipip} eq '' ) {
787       $recref->{slipip} = '';
788     } elsif ( $recref->{slipip} eq '0e0' ) {
789       $recref->{slipip} = '0e0';
790     } else {
791       $recref->{slipip} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
792         or return "Illegal slipip: ". $self->slipip;
793       $recref->{slipip} = $1;
794     }
795
796   }
797
798   #arbitrary RADIUS stuff; allow ut_textn for now
799   foreach ( grep /^radius_/, fields('svc_acct') ) {
800     $self->ut_textn($_);
801   }
802
803   #generate a password if it is blank
804   $recref->{_password} = join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
805     unless ( $recref->{_password} );
806
807   #if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{4,16})$/ ) {
808   if ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([^\t\n]{$passwordmin,$passwordmax})$/ ) {
809     $recref->{_password} = $1.$3;
810     #uncomment this to encrypt password immediately upon entry, or run
811     #bin/crypt_pw in cron to give new users a window during which their
812     #password is available to techs, for faxing, etc.  (also be aware of 
813     #radius issues!)
814     #$recref->{password} = $1.
815     #  crypt($3,$saltset[int(rand(64))].$saltset[int(rand(64))]
816     #;
817   } elsif ( $recref->{_password} =~ /^((\*SUSPENDED\* )?)([\w\.\/\$\;\+]{13,34})$/ ) {
818     $recref->{_password} = $1.$3;
819   } elsif ( $recref->{_password} eq '*' ) {
820     $recref->{_password} = '*';
821   } elsif ( $recref->{_password} eq '!!' ) {
822     $recref->{_password} = '!!';
823   } else {
824     #return "Illegal password";
825     return gettext('illegal_password'). " $passwordmin-$passwordmax ".
826            FS::Msgcat::_gettext('illegal_password_characters').
827            ": ". $recref->{_password};
828   }
829
830   $self->SUPER::check;
831 }
832
833 =item radius
834
835 Depriciated, use radius_reply instead.
836
837 =cut
838
839 sub radius {
840   carp "FS::svc_acct::radius depriciated, use radius_reply";
841   $_[0]->radius_reply;
842 }
843
844 =item radius_reply
845
846 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
847 reply attributes of this record.
848
849 Note that this is now the preferred method for reading RADIUS attributes - 
850 accessing the columns directly is discouraged, as the column names are
851 expected to change in the future.
852
853 =cut
854
855 sub radius_reply { 
856   my $self = shift;
857   my %reply =
858     map {
859       /^(radius_(.*))$/;
860       my($column, $attrib) = ($1, $2);
861       #$attrib =~ s/_/\-/g;
862       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
863     } grep { /^radius_/ && $self->getfield($_) } fields( $self->table );
864   if ( $self->slipip && $self->slipip ne '0e0' ) {
865     $reply{'Framed-IP-Address'} = $self->slipip;
866   }
867   %reply;
868 }
869
870 =item radius_check
871
872 Returns key/value pairs, suitable for assigning to a hash, for any RADIUS
873 check attributes of this record.
874
875 Note that this is now the preferred method for reading RADIUS attributes - 
876 accessing the columns directly is discouraged, as the column names are
877 expected to change in the future.
878
879 =cut
880
881 sub radius_check {
882   my $self = shift;
883   my $password = $self->_password;
884   my $pw_attrib = length($password) <= 12 ? $radius_password : 'Crypt-Password';
885   ( $pw_attrib => $password,
886     map {
887       /^(rc_(.*))$/;
888       my($column, $attrib) = ($1, $2);
889       #$attrib =~ s/_/\-/g;
890       ( $FS::raddb::attrib{lc($attrib)}, $self->getfield($column) );
891     } grep { /^rc_/ && $self->getfield($_) } fields( $self->table )
892   );
893 }
894
895 =item domain
896
897 Returns the domain associated with this account.
898
899 =cut
900
901 sub domain {
902   my $self = shift;
903   die "svc_acct.domsvc is null for svcnum ". $self->svcnum unless $self->domsvc;
904   my $svc_domain = $self->svc_domain
905     or die "no svc_domain.svcnum for svc_acct.domsvc ". $self->domsvc;
906   $svc_domain->domain;
907 }
908
909 =item svc_domain
910
911 Returns the FS::svc_domain record for this account's domain (see
912 L<FS::svc_domain>).
913
914 =cut
915
916 sub svc_domain {
917   my $self = shift;
918   $self->{'_domsvc'}
919     ? $self->{'_domsvc'}
920     : qsearchs( 'svc_domain', { 'svcnum' => $self->domsvc } );
921 }
922
923 =item cust_svc
924
925 Returns the FS::cust_svc record for this account (see L<FS::cust_svc>).
926
927 =cut
928
929 sub cust_svc {
930   my $self = shift;
931   qsearchs( 'cust_svc', { 'svcnum' => $self->svcnum } );
932 }
933
934 =item email
935
936 Returns an email address associated with the account.
937
938 =cut
939
940 sub email {
941   my $self = shift;
942   $self->username. '@'. $self->domain;
943 }
944
945 =item seconds_since TIMESTAMP
946
947 Returns the number of seconds this account has been online since TIMESTAMP,
948 according to the session monitor (see L<FS::Session>).
949
950 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
951 L<Time::Local> and L<Date::Parse> for conversion functions.
952
953 =cut
954
955 #note: POD here, implementation in FS::cust_svc
956 sub seconds_since {
957   my $self = shift;
958   $self->cust_svc->seconds_since(@_);
959 }
960
961 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
962
963 Returns the numbers of seconds this account has been online between
964 TIMESTAMP_START (inclusive) and TIMESTAMP_END (exclusive), according to an
965 external SQL radacct table, specified via sqlradius export.  Sessions which
966 started in the specified range but are still open are counted from session
967 start to the end of the range (unless they are over 1 day old, in which case
968 they are presumed missing their stop record and not counted).  Also, sessions
969 which end in therange but started earlier are counted from the start of the
970 range to session end.  Finally, sessions which start before the range but end
971 after are counted for the entire range.
972
973 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
974 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
975 functions.
976
977 =cut
978
979 #note: POD here, implementation in FS::cust_svc
980 sub seconds_since_sqlradacct {
981   my $self = shift;
982   $self->cust_svc->seconds_since_sqlradacct(@_);
983 }
984
985 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
986
987 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
988 in this package for sessions ending between TIMESTAMP_START (inclusive) and
989 TIMESTAMP_END (exclusive).
990
991 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
992 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
993 functions.
994
995 =cut
996
997 #note: POD here, implementation in FS::cust_svc
998 sub attribute_since_sqlradacct {
999   my $self = shift;
1000   $self->cust_svc->attribute_since_sqlradacct(@_);
1001 }
1002
1003 =item radius_groups
1004
1005 Returns all RADIUS groups for this account (see L<FS::radius_usergroup>).
1006
1007 =cut
1008
1009 sub radius_groups {
1010   my $self = shift;
1011   if ( $self->usergroup ) {
1012     #when provisioning records, export callback runs in svc_Common.pm before
1013     #radius_usergroup records can be inserted...
1014     @{$self->usergroup};
1015   } else {
1016     map { $_->groupname }
1017       qsearch('radius_usergroup', { 'svcnum' => $self->svcnum } );
1018   }
1019 }
1020
1021 =back
1022
1023 =head1 SUBROUTINES
1024
1025 =over 4
1026
1027 =item send_email
1028
1029 This is the FS::svc_acct job-queue-able version.  It still uses
1030 FS::Misc::send_email under-the-hood.
1031
1032 =cut
1033
1034 sub send_email {
1035   my %opt = @_;
1036
1037   eval "use FS::Misc qw(send_email)";
1038   die $@ if $@;
1039
1040   $opt{mimetype} ||= 'text/plain';
1041   $opt{mimetype} .= '; charset="iso-8859-1"' unless $opt{mimetype} =~ /charset/;
1042
1043   my $error = send_email(
1044     'from'         => $opt{from},
1045     'to'           => $opt{to},
1046     'subject'      => $opt{subject},
1047     'content-type' => $opt{mimetype},
1048     'body'         => [ map "$_\n", split("\n", $opt{body}) ],
1049   );
1050   die $error if $error;
1051 }
1052
1053 =item check_and_rebuild_fuzzyfiles
1054
1055 =cut
1056
1057 sub check_and_rebuild_fuzzyfiles {
1058   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1059   -e "$dir/svc_acct.username"
1060     or &rebuild_fuzzyfiles;
1061 }
1062
1063 =item rebuild_fuzzyfiles
1064
1065 =cut
1066
1067 sub rebuild_fuzzyfiles {
1068
1069   use Fcntl qw(:flock);
1070
1071   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1072
1073   #username
1074
1075   open(USERNAMELOCK,">>$dir/svc_acct.username")
1076     or die "can't open $dir/svc_acct.username: $!";
1077   flock(USERNAMELOCK,LOCK_EX)
1078     or die "can't lock $dir/svc_acct.username: $!";
1079
1080   my @all_username = map $_->getfield('username'), qsearch('svc_acct', {});
1081
1082   open (USERNAMECACHE,">$dir/svc_acct.username.tmp")
1083     or die "can't open $dir/svc_acct.username.tmp: $!";
1084   print USERNAMECACHE join("\n", @all_username), "\n";
1085   close USERNAMECACHE or die "can't close $dir/svc_acct.username.tmp: $!";
1086
1087   rename "$dir/svc_acct.username.tmp", "$dir/svc_acct.username";
1088   close USERNAMELOCK;
1089
1090 }
1091
1092 =item all_username
1093
1094 =cut
1095
1096 sub all_username {
1097   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1098   open(USERNAMECACHE,"<$dir/svc_acct.username")
1099     or die "can't open $dir/svc_acct.username: $!";
1100   my @array = map { chomp; $_; } <USERNAMECACHE>;
1101   close USERNAMECACHE;
1102   \@array;
1103 }
1104
1105 =item append_fuzzyfiles USERNAME
1106
1107 =cut
1108
1109 sub append_fuzzyfiles {
1110   my $username = shift;
1111
1112   &check_and_rebuild_fuzzyfiles;
1113
1114   use Fcntl qw(:flock);
1115
1116   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1117
1118   open(USERNAME,">>$dir/svc_acct.username")
1119     or die "can't open $dir/svc_acct.username: $!";
1120   flock(USERNAME,LOCK_EX)
1121     or die "can't lock $dir/svc_acct.username: $!";
1122
1123   print USERNAME "$username\n";
1124
1125   flock(USERNAME,LOCK_UN)
1126     or die "can't unlock $dir/svc_acct.username: $!";
1127   close USERNAME;
1128
1129   1;
1130 }
1131
1132
1133
1134 =item radius_usergroup_selector GROUPS_ARRAYREF [ SELECTNAME ]
1135
1136 =cut
1137
1138 sub radius_usergroup_selector {
1139   my $sel_groups = shift;
1140   my %sel_groups = map { $_=>1 } @$sel_groups;
1141
1142   my $selectname = shift || 'radius_usergroup';
1143
1144   my $dbh = dbh;
1145   my $sth = $dbh->prepare(
1146     'SELECT DISTINCT(groupname) FROM radius_usergroup ORDER BY groupname'
1147   ) or die $dbh->errstr;
1148   $sth->execute() or die $sth->errstr;
1149   my @all_groups = map { $_->[0] } @{$sth->fetchall_arrayref};
1150
1151   my $html = <<END;
1152     <SCRIPT>
1153     function ${selectname}_doadd(object) {
1154       var myvalue = object.${selectname}_add.value;
1155       var optionName = new Option(myvalue,myvalue,false,true);
1156       var length = object.$selectname.length;
1157       object.$selectname.options[length] = optionName;
1158       object.${selectname}_add.value = "";
1159     }
1160     </SCRIPT>
1161     <SELECT MULTIPLE NAME="$selectname">
1162 END
1163
1164   foreach my $group ( @all_groups ) {
1165     $html .= '<OPTION';
1166     if ( $sel_groups{$group} ) {
1167       $html .= ' SELECTED';
1168       $sel_groups{$group} = 0;
1169     }
1170     $html .= ">$group</OPTION>\n";
1171   }
1172   foreach my $group ( grep { $sel_groups{$_} } keys %sel_groups ) {
1173     $html .= "<OPTION SELECTED>$group</OPTION>\n";
1174   };
1175   $html .= '</SELECT>';
1176
1177   $html .= qq!<BR><INPUT TYPE="text" NAME="${selectname}_add">!.
1178            qq!<INPUT TYPE="button" VALUE="Add new group" onClick="${selectname}_doadd(this.form)">!;
1179
1180   $html;
1181 }
1182
1183 =back
1184
1185 =head1 BUGS
1186
1187 The $recref stuff in sub check should be cleaned up.
1188
1189 The suspend, unsuspend and cancel methods update the database, but not the
1190 current object.  This is probably a bug as it's unexpected and
1191 counterintuitive.
1192
1193 radius_usergroup_selector?  putting web ui components in here?  they should
1194 probably live somewhere else...
1195
1196 =head1 SEE ALSO
1197
1198 L<FS::svc_Common>, edit/part_svc.cgi from an installed web interface,
1199 export.html from the base documentation, L<FS::Record>, L<FS::Conf>,
1200 L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, L<FS::queue>,
1201 L<freeside-queued>), L<FS::svc_acct_pop>,
1202 schema.html from the base documentation.
1203
1204 =cut
1205
1206 1;
1207