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