use FS::access_user;
sub authenticate {
- my($self, $username, $check_password ) = @_;
+ my($self, $username, $check_password, $totp_code ) = @_;
my $access_user =
ref($username) ? $username
)
or return 0;
+ my $pw_check;
if ( $access_user->_password_encoding eq 'bcrypt' ) {
my( $cost, $salt, $hash ) = split(',', $access_user->_password);
)
);
- $hash eq $check_hash;
+ $pw_check = $hash eq $check_hash;
- } else {
+ } else {
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 authen_cred {
- my( $self, $r, $username, $password ) = @_;
+ my( $self, $r, $username, $password, $totp_code ) = @_;
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;
}
{
'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',
},
die $@ if $@;
}
use Text::CSV_XS;
+ use Archive::Zip;
use Spreadsheet::WriteExcel;
use Spreadsheet::WriteExcel::Utility;
use OLE::Storage_Lite;
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;
'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', '', '', '',
use FS::cust_main;
use FS::sales;
use Carp qw( croak );
+use Auth::GoogleAuth;
$DEBUG = 0;
$me = '[FS::access_user]';
$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')
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
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
});
}
+=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
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
inSR => 4326,
outSR => 4326,
spatialRel => 'esriSpatialRelIntersects', # the test to perform
- outFields => 'OID,GEOID',
+ outFields => 'GEOID',
returnGeometry => 'false',
orderByFields => 'OID',
);
#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,
- where => "OID>$last_oid",
+ resultOffset => $inserted,
]
);
die $response->status_line unless $response->is_success;
}
#warn "Inserted $inserted records\n";
- $last_oid = $data->{features}[-1]{attributes}{OID};
$done = 1 unless $data->{exceededTransferLimit};
}
use FS::template_content;
use Date::Format qw(time2str);
+use PDF::WebKit;
FS::UID->install_callback( sub { $conf = new FS::Conf; } );
sub render {
my $self = shift;
- eval "use PDF::WebKit";
- die $@ if $@;
my %opt = @_;
my %hash = $self->prepare(%opt);
my $html = $hash{'html_body'};
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),
$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 = ();
'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);
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
";
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,
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)
$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,
- '-expires' => '+'.$timeout.'s',
#'-secure' => 1, # would be a good idea...
);
if ( $name eq 'logout' ) {
};
+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 $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 = (
- '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';
'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',
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',
'(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,
'Service Type',
'Advertised Mbps',
'Vertices', # number of vertices? not so useful
+ 'Shapefile',
+ 'KMZ',
+ 'GeoJSON',
],
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)
+ ? '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',
'(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,
<& /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 $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>
<% 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',
<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>
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.',
);
</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>
--- /dev/null
+<& /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>
--- /dev/null
+<% $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>
--- /dev/null
+<% $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>
--- /dev/null
+<% $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>