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