default to a session cookie instead of setting an explicit timeout, weird timezone... master
authorIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
21 files changed:
FS/FS/Auth/internal.pm
FS/FS/AuthCookieHandler.pm
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/access_user.pm
FS/FS/deploy_zone.pm
FS/FS/msg_template.pm
FS/FS/password_history.pm
FS/bin/freeside-cdr-sftp_and_import
debian/control
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
httemplate/browse/access_user.html
httemplate/browse/deploy_zone.html
httemplate/edit/process/access_user.html
httemplate/loginout/login.html
httemplate/pref/pref.html
httemplate/pref/set_totp_secret32.html [new file with mode: 0644]
httemplate/view/deploy_zone-geojson.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-kmz.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-shp.cgi [new file with mode: 0644]

index dfc5f30..92dff03 100644 (file)
@@ -7,7 +7,7 @@ use FS::Record qw( qsearchs );
 use FS::access_user;
 
 sub authenticate {
 use FS::access_user;
 
 sub authenticate {
-  my($self, $username, $check_password ) = @_;
+  my($self, $username, $check_password, $totp_code ) = @_;
 
   my $access_user =
     ref($username) ? $username
 
   my $access_user =
     ref($username) ? $username
@@ -17,6 +17,7 @@ sub authenticate {
                              )
     or return 0;
 
                              )
     or return 0;
 
+  my $pw_check;
   if ( $access_user->_password_encoding eq 'bcrypt' ) {
 
     my( $cost, $salt, $hash ) = split(',', $access_user->_password);
   if ( $access_user->_password_encoding eq 'bcrypt' ) {
 
     my( $cost, $salt, $hash ) = split(',', $access_user->_password);
@@ -29,17 +30,21 @@ sub authenticate {
                                            )
                               );
 
                                            )
                               );
 
-    $hash eq $check_hash;
+    $pw_check = $hash eq $check_hash;
 
 
-  } else { 
+  } else {
 
     return 0 if $access_user->_password eq 'notyet'
              || $access_user->_password eq '';
 
 
     return 0 if $access_user->_password eq 'notyet'
              || $access_user->_password eq '';
 
-    $access_user->_password eq $check_password;
+    $pw_check = $access_user->_password eq $check_password;
 
   }
 
 
   }
 
+  return $pw_check if ! $pw_check || ! length($access_user->totp_secret32);
+
+  #2fa
+  $access_user->google_auth->verify( $totp_code, 1 );
 }
 
 sub autocreate { 0; }
 }
 
 sub autocreate { 0; }
index 93d8ea6..b7d0dbf 100644 (file)
@@ -13,13 +13,13 @@ sub useragent_ip {
 }
 
 sub authen_cred {
 }
 
 sub authen_cred {
-  my( $self, $r, $username, $password ) = @_;
+  my( $self, $r, $username, $password, $totp_code ) = @_;
 
   preuser_setup();
 
   my $info = {};
 
 
   preuser_setup();
 
   my $info = {};
 
-  unless ( FS::Auth->authenticate($username, $password, $info) ) {
+  unless ( FS::Auth->authenticate($username, $password, $totp_code, $info) ) {
     warn "failed auth $username from ". $self->useragent_ip($r). "\n";
     return undef;
   }
     warn "failed auth $username from ". $self->useragent_ip($r). "\n";
     return undef;
   }
index 383fb0e..57a8867 100644 (file)
@@ -2452,8 +2452,8 @@ and customer address. Include units.',
 
   {
     'key'         => 'selfservice-timeout',
 
   {
     'key'         => 'selfservice-timeout',
-    'section'     => 'self-service',
-    'description' => 'Timeout for the self-service login cookie, in seconds.  Defaults to 1 hour.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Was the timeout for the self-service login cookie, in seconds.  Defaulted to 1 hour.',
     'type'        => 'text',
   },
 
     'type'        => 'text',
   },
 
index 8dd72ac..ebd40ad 100644 (file)
@@ -85,6 +85,7 @@ if ( -e $addl_handler_use_file ) {
     die $@ if $@;
   }
   use Text::CSV_XS;
     die $@ if $@;
   }
   use Text::CSV_XS;
+  use Archive::Zip;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
   use OLE::Storage_Lite;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
   use OLE::Storage_Lite;
@@ -120,7 +121,10 @@ if ( -e $addl_handler_use_file ) {
   use Locale::Currency::Format;
   use Number::Phone::Country qw( noexport );
   use Business::US::USPS::WebTools::AddressStandardization;
   use Locale::Currency::Format;
   use Number::Phone::Country qw( noexport );
   use Business::US::USPS::WebTools::AddressStandardization;
-  use Geo::GoogleEarth::Pluggable;
+  use Geo::GoogleEarth::Pluggable 0.16;
+  use Geo::Shapelib;
+  use Geo::JSON;
+  use Geo::JSON::FeatureCollection;
   use LWP::UserAgent;
   use Storable qw( nfreeze thaw );
   use FS;
   use LWP::UserAgent;
   use Storable qw( nfreeze thaw );
   use FS;
index d884036..61b793b 100644 (file)
@@ -5931,6 +5931,7 @@ sub tables_hashref {
         'username',           'varchar',     '', $char_d, '', '',
         '_password',          'varchar', 'NULL', $char_d, '', '',
         '_password_encoding', 'varchar', 'NULL', $char_d, '', '',
         'username',           'varchar',     '', $char_d, '', '',
         '_password',          'varchar', 'NULL', $char_d, '', '',
         '_password_encoding', 'varchar', 'NULL', $char_d, '', '',
+        'totp_secret32',         'char', 'NULL',      32, '', '',
         'last',               'varchar', 'NULL', $char_d, '', '', 
         'first',              'varchar', 'NULL', $char_d, '', '', 
         'user_custnum',           'int', 'NULL',      '', '', '',
         'last',               'varchar', 'NULL', $char_d, '', '', 
         'first',              'varchar', 'NULL', $char_d, '', '', 
         'user_custnum',           'int', 'NULL',      '', '', '',
index f23aa77..270f8bb 100644 (file)
@@ -13,6 +13,7 @@ use FS::agent;
 use FS::cust_main;
 use FS::sales;
 use Carp qw( croak );
 use FS::cust_main;
 use FS::sales;
 use Carp qw( croak );
+use Auth::GoogleAuth;
 
 $DEBUG = 0;
 $me = '[FS::access_user]';
 
 $DEBUG = 0;
 $me = '[FS::access_user]';
@@ -239,6 +240,7 @@ sub check {
     $self->ut_numbern('usernum')
     || $self->ut_alpha_lower('username')
     || $self->ut_textn('_password')
     $self->ut_numbern('usernum')
     || $self->ut_alpha_lower('username')
     || $self->ut_textn('_password')
+    || $self->ut_alphan('totp_secret32')
     || $self->ut_textn('last')
     || $self->ut_textn('first')
     || $self->ut_foreign_keyn('user_custnum', 'cust_main', 'custnum')
     || $self->ut_textn('last')
     || $self->ut_textn('first')
     || $self->ut_foreign_keyn('user_custnum', 'cust_main', 'custnum')
@@ -733,6 +735,44 @@ sub change_password_fields {
   FS::Auth->auth_class->change_password_fields( @_ );
 }
 
   FS::Auth->auth_class->change_password_fields( @_ );
 }
 
+=item google_auth
+
+=cut
+
+sub google_auth {
+  my( $self ) = @_;
+  my $issuer = FS::Conf->new->config('company_name'). ' Freeside';
+  my $label = $issuer. ':'. $self->username;
+
+  Auth::GoogleAuth->new({
+    secret => $self->totp_secret32,
+    issuer => $issuer,
+    key_id => $label,
+  });
+
+}
+
+=item set_totp_secret32
+
+=cut
+
+sub set_totp_secret32 {
+  my( $self ) = @_;
+
+  $self->totp_secret32( $self->google_auth->generate_secret32 );
+  $self->replace;
+}
+
+=item totp_qr_code_url
+
+=cut
+
+sub totp_qr_code_url {
+  my( $self ) = @_;
+
+  $self->google_auth->qr_code;
+}
+
 =item locale
 
 =cut
 =item locale
 
 =cut
index 723b491..c618fb9 100644 (file)
@@ -10,8 +10,12 @@ use Cpanel::JSON::XS;
 use LWP::UserAgent;
 use HTTP::Request::Common;
 
 use LWP::UserAgent;
 use HTTP::Request::Common;
 
-# update this in 2020, along with the URL for the TIGERweb service
-our $CENSUS_YEAR = 2010;
+use Geo::JSON::Polygon;
+use Geo::JSON::Feature;
+
+our $CENSUS_YEAR = 2020;
+
+our $tech_label  = FS::part_pkg_fcc_option->technology_labels;
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -275,6 +279,27 @@ sub deploy_zone_vertex {
   });
 }
 
   });
 }
 
+=item shapefile_add SHAPEFILE
+
+Adds this deployment zone to the supplied Geo::Shapelib shapefile.
+
+=cut
+
+sub shapefile_add {
+  my( $self, $shapefile ) = @_;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude, 0, 0 ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  push @{$shapefile->{Shapes}}, { 'Vertices' => \@coordinates };
+  push @{$shapefile->{ShapeRecords}}, [ $tech_label->{$self->technology},
+                                        $self->adv_speed_down,
+                                        $self->adv_speed_up,
+                                      ];
+  '';
+}
+
 =item vertices_json
 
 Returns the vertex list for this zone, as a JSON string of
 =item vertices_json
 
 Returns the vertex list for this zone, as a JSON string of
@@ -289,6 +314,51 @@ sub vertices_json {
   encode_json(\@vertices);
 }
 
   encode_json(\@vertices);
 }
 
+=item geo_json_feature
+
+Returns this zone as a Geo::JSON::Feature object
+
+=cut
+
+sub geo_json_feature {
+  my $self = shift;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  Geo::JSON::Feature->new({
+    geometry   => Geo::JSON::Polygon->new({ coordinates => [ \@coordinates ] }),
+    properties => { 'Technology' => $tech_label->{$self->technology},
+                    'Down'       => $self->adv_speed_down,
+                    'Up'         => $self->adv_speed_up,
+                  },
+  })
+}
+
+=item kml_add
+
+Adds this deployment zone to the supplied Geo::GoogleEarth::Pluggable object.
+
+=cut
+
+sub kml_polygon {
+  my( $self, $kml ) = @_;
+
+  my $name = $self->description. ' ('. $self->adv_speed_down. '/'.
+                                       $self->adv_speed_up. ')';
+
+  $kml->Polygon( 'name'        => $name,
+                 'coordinates' => [ [ #outerBoundary
+                                      map { [ $_->longitude, $_->latitude, 0 ] }
+                                        $self->deploy_zone_vertex
+                                    ],
+                                    #[ #innerBoundary
+                                    #]
+                                  ]
+               );
+}
+
 =head2 SUBROUTINES
 
 =over 4
 =head2 SUBROUTINES
 
 =over 4
@@ -386,7 +456,7 @@ sub process_block_lookup {
     inSR            => 4326,
     outSR           => 4326,
     spatialRel      => 'esriSpatialRelIntersects', # the test to perform
     inSR            => 4326,
     outSR           => 4326,
     spatialRel      => 'esriSpatialRelIntersects', # the test to perform
-    outFields       => 'OID,GEOID',
+    outFields       => 'GEOID',
     returnGeometry  => 'false',
     orderByFields   => 'OID',
   );
     returnGeometry  => 'false',
     orderByFields   => 'OID',
   );
@@ -410,16 +480,12 @@ sub process_block_lookup {
 
   #warn "Census block lookup: $count\n";
 
 
   #warn "Census block lookup: $count\n";
 
-  # we have to do our own pagination on this, because the census bureau
-  # doesn't support resultOffset (maybe they don't have ArcGIS 10.3 yet).
-  # that's why we're ordering by OID, it's globally unique
-  my $last_oid = 0;
   my $done = 0;
   while (!$done) {
     $response = $ua->request(
       POST $url, Content => [
         %query,
   my $done = 0;
   while (!$done) {
     $response = $ua->request(
       POST $url, Content => [
         %query,
-        where => "OID>$last_oid",
+        resultOffset => $inserted,
       ]
     );
     die $response->status_line unless $response->is_success;
       ]
     );
     die $response->status_line unless $response->is_success;
@@ -444,7 +510,6 @@ sub process_block_lookup {
     }
 
     #warn "Inserted $inserted records\n";
     }
 
     #warn "Inserted $inserted records\n";
-    $last_oid = $data->{features}[-1]{attributes}{OID};
     $done = 1 unless $data->{exceededTransferLimit};
   }
 
     $done = 1 unless $data->{exceededTransferLimit};
   }
 
index 0a16724..33e150a 100644 (file)
@@ -11,6 +11,7 @@ use FS::cust_msg;
 use FS::template_content;
 
 use Date::Format qw(time2str);
 use FS::template_content;
 
 use Date::Format qw(time2str);
+use PDF::WebKit;
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
@@ -411,8 +412,6 @@ Options are as for 'prepare', but 'from' and 'to' are meaningless.
 
 sub render {
   my $self = shift;
 
 sub render {
   my $self = shift;
-  eval "use PDF::WebKit";
-  die $@ if $@;
   my %opt = @_;
   my %hash = $self->prepare(%opt);
   my $html = $hash{'html_body'};
   my %opt = @_;
   my %hash = $self->prepare(%opt);
   my $html = $hash{'html_body'};
index 13d1601..1915a21 100644 (file)
@@ -173,6 +173,7 @@ sub _upgrade_schema {
     push @where, "
       ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
   }
     push @where, "
       ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
   }
+  return '' unless @where;
   my @recs = qsearch({
       'table'     => 'password_history',
       'extra_sql' => ' WHERE ' . join(' AND ', @where),
   my @recs = qsearch({
       'table'     => 'password_history',
       'extra_sql' => ' WHERE ' . join(' AND ', @where),
index bae8051..f2bf294 100755 (executable)
@@ -21,8 +21,8 @@ $opt_e =~ s/^\.//;
 
 $opt_p ||= '';
 
 
 $opt_p ||= '';
 
-die "invalid cdrtypenum" if $opt_c && $opt_c !~ /^\d+$/;
-die "invalid carrierid"  if $opt_i && $opt_i !~ /^\d+$/;
+die "invalid cdrtypenum" if defined $opt_c && $opt_c !~ /^\d+$/;
+die "invalid carrierid"  if defined $opt_i && $opt_i !~ /^\d+$/;
 
 my %options = ();
 
 
 my %options = ();
 
@@ -114,8 +114,8 @@ foreach my $filename ( @$ls ) {
     'batch_namevalue' => $file_timestamp,
     'empty_ok'        => 1,
   };
     'batch_namevalue' => $file_timestamp,
     'empty_ok'        => 1,
   };
-  $import_options->{'cdrtypenum'} = $opt_c if $opt_c;
-  $import_options->{'carrierid'}  = $opt_i if $opt_i;
+  $import_options->{'cdrtypenum'} = $opt_c if defined $opt_c;
+  $import_options->{'carrierid'}  = $opt_i if defined $opt_i;
   
   my $error = FS::cdr::batch_import($import_options);
 
   
   my $error = FS::cdr::batch_import($import_options);
 
@@ -164,7 +164,7 @@ foreach my $filename ( @$ls ) {
 
 sub usage {
   "Usage:
 
 sub usage {
   "Usage:
-  cdr.sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ] 
+  freeside-cdr-sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ]
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
     [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
   ";
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
     [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
   ";
index 10a9df0..043294f 100644 (file)
@@ -78,7 +78,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libipc-run-safehandles-perl,libpoe-perl,libsoap-lite-perl,libxmlrpc-lite-perl,
  libhtml-tableextract-perl,libhtml-element-extended-perl,libcam-pdf-perl,
  libnet-openssh-perl,libgd-barcode-perl,sam2p,libsys-sigaction-perl,
  libipc-run-safehandles-perl,libpoe-perl,libsoap-lite-perl,libxmlrpc-lite-perl,
  libhtml-tableextract-perl,libhtml-element-extended-perl,libcam-pdf-perl,
  libnet-openssh-perl,libgd-barcode-perl,sam2p,libsys-sigaction-perl,
- libgeo-googleearth-pluggable-perl,libgeo-coder-googlev3-perl,libnet-snmp-perl,
+ libgeo-googleearth-pluggable-perl (>=0.16),libgeo-coder-googlev3-perl,
+ libnet-snmp-perl,
  libcrypt-openssl-rsa-perl,libregexp-common-perl,libnet-cidr-perl,
  libregexp-ipv6-perl,libhtml-quoted-perl,libtext-password-pronounceable-perl,
  libconvert-color-perl,liburi-perl,libhtml-rewriteattributes-perl,
  libcrypt-openssl-rsa-perl,libregexp-common-perl,libnet-cidr-perl,
  libregexp-ipv6-perl,libhtml-quoted-perl,libtext-password-pronounceable-perl,
  libconvert-color-perl,liburi-perl,libhtml-rewriteattributes-perl,
@@ -107,7 +108,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libspreadsheet-parsexlsx-perl, libunicode-truncate-perl (>= 0.303-1),
  libspreadsheet-xlsx-perl, libpod-simple-perl, libwebservice-northern911-perl,
  liblocale-codes-perl, liblocale-po-perl, libgeo-uscensus-geocoding-perl,
  libspreadsheet-parsexlsx-perl, libunicode-truncate-perl (>= 0.303-1),
  libspreadsheet-xlsx-perl, libpod-simple-perl, libwebservice-northern911-perl,
  liblocale-codes-perl, liblocale-po-perl, libgeo-uscensus-geocoding-perl,
- libnet-sftp-foreign-perl
+ libnet-sftp-foreign-perl, libpdf-webkit-perl, libgeo-shapelib-perl,
+ libgeo-json-perl, libauth-googleauth-perl
 Conflicts: libparams-classify-perl (>= 0.013-6)
 Replaces: freeside (<<4)
 Breaks: freeside (<<4)
 Conflicts: libparams-classify-perl (>= 0.013-6)
 Replaces: freeside (<<4)
 Breaks: freeside (<<4)
index 6eab11d..b1fea7d 100755 (executable)
@@ -1250,10 +1250,8 @@ sub do_template {
   $fill_in->{$_} = $access_info->{$_} foreach keys %$access_info;
 
   # update the user's authentication
   $fill_in->{$_} = $access_info->{$_} foreach keys %$access_info;
 
   # update the user's authentication
-  my $timeout = $access_info->{'timeout'} || '3600';
   my $cookie = CGI::Cookie->new('-name'     => 'session',
                                 '-value'    => $session_id,
   my $cookie = CGI::Cookie->new('-name'     => 'session',
                                 '-value'    => $session_id,
-                                '-expires'  => '+'.$timeout.'s',
                                 #'-secure'   => 1, # would be a good idea...
                                );
   if ( $name eq 'logout' ) {
                                 #'-secure'   => 1, # would be a good idea...
                                );
   if ( $name eq 'logout' ) {
index 446bfe0..6587627 100644 (file)
@@ -49,6 +49,11 @@ my $groups_sub = sub {
 
 };
 
 
 };
 
+my $goog_auth_sub = sub {
+  my $access_user = shift;
+  $access_user->totp_secret32 ? 'Enabled' : '';
+};
+
 my $installer_sub = sub {
   my $access_user = shift;
   my @sched_item = $access_user->sched_item or return '';
 my $installer_sub = sub {
   my $access_user = shift;
   my @sched_item = $access_user->sched_item or return '';
@@ -66,11 +71,23 @@ my $count_query = 'SELECT COUNT(*) FROM access_user';
 my $link = [ $p.'edit/access_user.html?', 'usernum' ];
 
 my @header = (
 my $link = [ $p.'edit/access_user.html?', 'usernum' ];
 
 my @header = (
-  'Username', 'Full name', 'Groups',    'Installer',    'Customer' );
+  'Username',
+  'Full name',
+  'Groups',
+  'Google Auth',
+  'Installer',
+  'Customer',
+);
 my @fields = (
 my @fields = (
-  'username', 'name',      $groups_sub, $installer_sub, $cust_sub, );
-my $align = 'lllcl';
-my @links = ( $link, $link, $link, '', '', $cust_link );
+  'username',
+  'name',
+  $groups_sub,
+  $goog_auth_sub,
+  $installer_sub,
+  $cust_sub,
+);
+my $align = 'lllccl';
+my @links = ( $link, $link, $link, '', '', '', $cust_link );
 
 #if ( FS::Conf->new->config('ticket_system') ) {
 #  push @header, 'Ticketing';
 
 #if ( FS::Conf->new->config('ticket_system') ) {
 #  push @header, 'Ticketing';
index 5514d7d..0533d6e 100644 (file)
                         'Contractual Mbps',
                         'Vertices',
                         'Census blocks',
                         'Contractual Mbps',
                         'Vertices',
                         'Census blocks',
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
+                     ],
+  footer          => [ '',
+                       'All fixed zones',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '<A HREF="'. $fixed_shp.  '">download</A>',
+                       '<A HREF="'. $fixed_kmz.  '">download</A>',
+                       '<A HREF="'. $fixed_json. '">download</A>',
                      ],
   fields          => [  'zonenum',
                         'description',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_block->count('zonenum = '.$self->zonenum)
                             },
                         sub { my $self = shift;
                               FS::deploy_zone_block->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
@@ -56,7 +83,7 @@
                        '(adv_speed_down, adv_speed_up)',
                        '(cir_speed_down, cir_speed_up)',
                      ],
                        '(adv_speed_down, adv_speed_up)',
                        '(cir_speed_down, cir_speed_up)',
                      ],
-  links           => [  $link_fixed, $link_fixed, ],
+  links           => [  $link_fixed, $link_fixed, '', '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'cllllrrr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
   align           => 'cllllrrr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
                         'Service Type',
                         'Advertised Mbps',
                         'Vertices', # number of vertices? not so useful
                         'Service Type',
                         'Advertised Mbps',
                         'Vertices', # number of vertices? not so useful
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
                      ],
   fields          => [  'zonenum',
                         'description',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
                             },
                         sub { my $self = shift;
                               FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                        '(is_voice is not null, is_broadband is not null)',
                        '(adv_speed_down, adv_speed_up)',
                      ],
                        '(is_voice is not null, is_broadband is not null)',
                        '(adv_speed_down, adv_speed_up)',
                      ],
-  links           => [  '', $link_mobile, ],
+  links           => [  $link_mobile, $link_mobile, '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'clllllr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
   align           => 'clllllr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
 
 <& /elements/footer.html &>
 <%init>
 
 <& /elements/footer.html &>
 <%init>
+
 my $curuser = $FS::CurrentUser::CurrentUser;
 my $acl_edit = $curuser->access_right('Edit FCC report configuration');
 my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
 die "access denied"
   unless $acl_edit or $acl_edit_global;
 
 my $curuser = $FS::CurrentUser::CurrentUser;
 my $acl_edit = $curuser->access_right('Edit FCC report configuration');
 my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
 die "access denied"
   unless $acl_edit or $acl_edit_global;
 
-my $link_fixed = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
-my $link_mobile= [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_fixed  = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
+my $link_mobile = [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_shp    = [ $p.'view/deploy_zone-shp.cgi?', 'zonenum' ];
+my $link_kmz    = [ $p.'view/deploy_zone-kmz.cgi?', 'zonenum' ];
+my $link_json   = [ $p.'view/deploy_zone-geojson.cgi?', 'zonenum' ];
+
+my $fixed_shp    = $p.'view/deploy_zone-shp.cgi?zonetype=B';
+my $fixed_kmz    = $p.'view/deploy_zone-kmz.cgi?zonetype=B';
+my $fixed_json   = $p.'view/deploy_zone-geojson.cgi?zonetype=B';
+
+my $tech_label  = FS::part_pkg_fcc_option->technology_labels;
+my $spec_label  = FS::part_pkg_fcc_option->spectrum_labels;
 
 
-my $tech_label = FS::part_pkg_fcc_option->technology_labels;
-my $spec_label = FS::part_pkg_fcc_option->spectrum_labels;
 </%init>
 </%init>
index c272620..8e264c1 100644 (file)
@@ -5,7 +5,7 @@
 <%   include( 'elements/process.html',
                  'table'          => 'access_user',
                  'viewall_dir'    => 'browse',
 <%   include( 'elements/process.html',
                  'table'          => 'access_user',
                  'viewall_dir'    => 'browse',
-                 'copy_on_empty'  => [ '_password', '_password_encoding' ],
+                 'copy_on_empty'  => [ '_password', '_password_encoding', 'totp_secret32' ],
                  'clear_on_error' => [ '_password', '_password2' ],
                  'process_m2m'    => { 'link_table'   => 'access_usergroup',
                                        'target_table' => 'access_group',
                  'clear_on_error' => [ '_password', '_password2' ],
                  'process_m2m'    => { 'link_table'   => 'access_usergroup',
                                        'target_table' => 'access_group',
index 72e9525..1785ea7 100644 (file)
         <TD ALIGN="right">Password: </TD>
         <TD><INPUT TYPE="password" NAME="credential_1" SIZE="13"></TD>
       </TR>
         <TD ALIGN="right">Password: </TD>
         <TD><INPUT TYPE="password" NAME="credential_1" SIZE="13"></TD>
       </TR>
+      <TR>
+        <TD ALIGN="right">One-time code: </TD>
+        <TD><INPUT TYPE="text" NAME="credential_2" SIZE="13"></TD>
+      </TR>
     </TABLE>
     <BR>
  
     </TABLE>
     <BR>
  
@@ -42,7 +46,7 @@
 my %error = (
   'no_cookie'       => '', #First login, don't display an error
   'bad_cookie'      => 'Bad Cookie', #timed out?
 my %error = (
   'no_cookie'       => '', #First login, don't display an error
   'bad_cookie'      => 'Bad Cookie', #timed out?
-  'bad_credentials' => 'Incorrect username / password',
+  'bad_credentials' => 'Incorrect username / password / one-time code',
   #'logout'          => 'You have been logged out.',
 );
 
   #'logout'          => 'You have been logged out.',
 );
 
index 56fde6d..5f68d3e 100644 (file)
     </TABLE>
     <BR>
 
     </TABLE>
     <BR>
 
+    <FONT CLASS="fsinnerbox-title"><% emt('Google Authenticator') %></FONT>
+    <TABLE CLASS="fsinnerbox">
+      <TR>
+%       if ( $curuser->totp_secret32 ) {
+          <TD><IMG SRC="<% $curuser->totp_qr_code_url %>"</IMG></TD>
+%       } else {
+          <TD><A HREF="<%$p%>pref/set_totp_secret32.html">Enable</A></TD>
+%       }
+      </TR>
+    </TABLE>
+    <BR>
+
 % }
 
 <FONT CLASS="fsinnerbox-title"><% emt("Interface") %></FONT>
 % }
 
 <FONT CLASS="fsinnerbox-title"><% emt("Interface") %></FONT>
diff --git a/httemplate/pref/set_totp_secret32.html b/httemplate/pref/set_totp_secret32.html
new file mode 100644 (file)
index 0000000..f5676bc
--- /dev/null
@@ -0,0 +1,19 @@
+<& /elements/header.html, mt('Google Authenticator for [_1]', $FS::CurrentUser::CurrentUser->username) &>
+
+Scan this code with the Google Authenticator application on your phone.
+<BR><BR>
+
+<IMG SRC="<% $access_user->totp_qr_code_url %>"></IMG>
+<BR><BR>
+
+Future logins will require a 6-digit code generated by the application.
+
+<& /elements/footer.html &>
+<%init>
+
+my $access_user = $FS::CurrentUser::CurrentUser;
+
+my $error = $access_user->set_totp_secret32 unless length($access_user->totp_secret32);
+die $error if $error; #better error handling for this "shouldn't happen" case?
+
+</%init>
diff --git a/httemplate/view/deploy_zone-geojson.cgi b/httemplate/view/deploy_zone-geojson.cgi
new file mode 100644 (file)
index 0000000..0c9d193
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my($name, $content);
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $content = $deploy_zone->geo_json_feature->to_json;
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+   my $fc = Geo::JSON::FeatureCollection->new({
+     features => [ map $_->geo_json_feature, @deploy_zone ],
+   });
+
+   $content = $fc->to_json;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+http_header('Content-Type'        => 'application/geo+json' );
+http_header('Content-Disposition' => "filename=$name.geojson" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-kmz.cgi b/httemplate/view/deploy_zone-kmz.cgi
new file mode 100644 (file)
index 0000000..d2af171
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $kml = Geo::GoogleEarth::Pluggable->new;
+
+my $name;
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $deploy_zone->kml_polygon($kml);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $_->kml_polygon($kml) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+my $content = $kml->archive;
+
+http_header('Content-Type' => 'application/vnd.google-earth.kmz' ); #kmz
+http_header('Content-Disposition' => "filename=$name.kmz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-shp.cgi b/httemplate/view/deploy_zone-shp.cgi
new file mode 100644 (file)
index 0000000..4b5dfbb
--- /dev/null
@@ -0,0 +1,76 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+
+my %shapelib_opts = (
+  Shapetype  => Geo::Shapelib::POLYGON,
+  FieldNames => [ 'Tech', 'Down', 'Up' ],
+  FieldTypes => [ 'String:32', 'Double', 'Double' ],
+);
+
+my( $name, $shapefile );
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonenum-$$",
+    %shapelib_opts
+  };
+
+  $deploy_zone->shapefile_add($shapefile);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonetype-$$",
+    %shapelib_opts
+  };
+
+  $_->shapefile_add($shapefile) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+$shapefile->set_bounds;
+
+$shapefile->save;
+
+#slurp up .shp .shx and .dbf files and put them in a zip.. return that
+#and delete the files
+
+my $content = '';
+open(my $fh, '>', \$content);
+
+my $zip = new Archive::Zip;
+$zip->addFile("$dir/$name-$$.$_", "$name.$_") foreach qw( shp shx dbf );
+unless ( $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() ) {
+  die "failed to create .shz file\n";
+}
+close $fh;
+
+unlink("$dir/$name-$$.$_") foreach qw( shp shx dbf );
+
+#http_header('Content-Type'        => 'x-gis/x-shapefile' );
+http_header('Content-Type'        => 'archive/zip' );
+http_header('Content-Disposition' => "filename=$name.shz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>