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