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