Bug 39190: Rework (S)FTP into File::Transport classes

This patch adds polymorphic transport classes to Koha for FTP and SFTP
file transport configurations.

We introduce a base class for interacting with the base configuration
storage and subclasses to impliment the protocol specific transport
handling.

Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Martin Renvoize 2025-01-17 12:21:35 +00:00 committed by Lucas Gass
parent 9d76089166
commit 3354bed6ef
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
7 changed files with 1042 additions and 0 deletions

268
Koha/File/Transport.pm Normal file
View File

@ -0,0 +1,268 @@
package Koha::File::Transport;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use constant {
DEFAULT_TIMEOUT => 10,
TEST_FILE_NAME => '.koha_test_file',
TEST_FILE_CONTENT => "Hello, world!\n",
KEY_FILE_PERMISSIONS => '0600',
};
use Koha::Database;
use Koha::Exceptions::Object;
use Koha::Encryption;
use base qw(Koha::Object);
=head1 NAME
Koha::File::Transport - Base class for file transport handling
=head1 DESCRIPTION
Base class providing common functionality for FTP/SFTP file transport.
=cut
=head1 API
=head2 Class methods
=head3 store
$server->store;
Overloaded store method that ensures directory paths end with a forward slash.
=cut
sub store {
my ($self) = @_;
# Encrypt sensitive data if changed
$self->_encrypt_sensitive_data();
# Normalize directory paths
for my $dir_field (qw(download_directory upload_directory)) {
my $dir = $self->$dir_field;
next unless $dir && $dir ne '';
$self->$dir_field( $dir . '/' ) unless substr( $dir, -1 ) eq '/';
}
# Store
$self->SUPER::store;
# Return the updated object including the encrypt_sensitive_data
return $self->discard_changes;
}
=head3 plain_text_password
my $password = $server->plain_text_password;
Returns the decrypted plaintext password.
=cut
sub plain_text_password {
my ($self) = @_;
return unless $self->password;
return Koha::Encryption->new->decrypt_hex( $self->password );
}
=head3 plain_text_key
my $key = $server->plain_text_key;
Returns the decrypted plaintext key file.
=cut
sub plain_text_key {
my ($self) = @_;
return unless $self->key_file;
return Koha::Encryption->new->decrypt_hex( $self->key_file ) . "\n";
}
=head3 to_api
my $json = $transport->to_api;
Returns a JSON representation of the object suitable for API output,
excluding sensitive data.
=cut
sub to_api {
my ( $self, $params ) = @_;
my $json = $self->SUPER::to_api($params) or return;
delete @{$json}{qw(password key_file)}; # Remove sensitive data
return $json;
}
=head3 to_api_mapping
This method returns the mapping for representing a Koha::File::Transport object
on the API.
=cut
sub to_api_mapping {
return { id => 'sftp_server_id' };
}
=head3 test_connection
$transport->test_connection
Method to test the connection for the configuration of the current file server
=cut
sub test_connection {
my ($self) = @_;
$self->connect or return;
for my $dir_type (qw(download upload)) {
my $dir = $self->{"${dir_type}_directory"};
next if $dir eq '';
$self->change_directory($dir) or return;
$self->list_files() or return;
}
return 1;
}
=head2 Subclass methods
Interface methods that must be implemented by subclasses
=head3 connect
$transport->connect();
Method for connecting the current transport to the file server
=cut
sub connect {
my ($self) = @_;
die "Subclass must implement connect";
}
=head3 upload_file
$transport->upload_file($file);
Method for uploading a file to the current file server
=cut
sub upload_file {
my ( $self, $local_file, $remote_file ) = @_;
die "Subclass must implement upload_file";
}
=head3 download_file
$transport->download_file($file);
Method for downloading a file from the current file server
=cut
sub download_file {
my ( $self, $remote_file, $local_file ) = @_;
die "Subclass must implement download_file";
}
=head3 change_directory
my $files = $transport->change_directory($path);
Method for changing the current directory on the connected file server
=cut
sub change_directory {
my ( $self, $path ) = @_;
die "Subclass must implement change_directory";
}
=head3 list_files
my $files = $transport->list_files($path);
Method for listing files in the current directory of the connected file server
=cut
sub list_files {
my ( $self, $path ) = @_;
die "Subclass must implement list_files";
}
=head2 Internal methods
=head3 _encrypt_sensitive_data
Handle encryption of sensitive data
=cut
sub _encrypt_sensitive_data {
my ($self) = @_;
my $encryption = Koha::Encryption->new;
# Only encrypt if the value has changed (is_changed from Koha::Object)
if ( ( !$self->in_storage || $self->is_changed('password') ) && $self->password ) {
$self->password( $encryption->encrypt_hex( $self->password ) );
}
if ( ( !$self->in_storage || $self->is_changed('key_file') ) && $self->key_file ) {
$self->key_file( $encryption->encrypt_hex( _dos2unix( $self->key_file ) ) );
}
}
=head3 _dos2unix
Return a CR-free string from an input
=cut
sub _dos2unix {
my $dosStr = shift;
return $dosStr =~ s/\015\012/\012/gr;
}
=head3 _type
Return type of Object relating to Schema Result
=cut
sub _type {
return 'SftpServer';
}
1;

183
Koha/File/Transport/FTP.pm Normal file
View File

@ -0,0 +1,183 @@
package Koha::File::Transport::FTP;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Net::FTP;
use Try::Tiny;
use base qw(Koha::File::Transport);
=head1 NAME
Koha::File::Transport::FTP - FTP implementation of file transport
=head2 Class methods
=head3 connect
my $success = $self->connect;
Start the FTP transport connect, returns true on success or undefined on failure.
=cut
sub connect {
my ($self) = @_;
$self->{connection} = Net::FTP->new(
$self->host,
Port => $self->port,
Timeout => $self->DEFAULT_TIMEOUT,
Passive => $self->passive ? 1 : 0,
) or return $self->_abort_operation();
$self->{connection}->login( $self->user_name, $self->plain_text_password )
or return $self->_abort_operation();
$self->add_message(
{
message => "Connect succeeded",
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
=head3 upload_file
my $success = $transport->upload_file($fh);
Passed a filehandle, this will upload the file to the current directory of the server connection.
Returns true on success or undefined on failure.
=cut
sub upload_file {
my ( $self, $local_file, $remote_file ) = @_;
$self->{connection}->put( $local_file, $remote_file )
or return $self->_abort_operation();
$self->add_message(
{
message => "Upload succeeded",
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
=head3 download_file
my $success = $transport->download_file($filename);
Passed a filename, this will download the file from the current directory of the server connection.
Returns true on success or undefined on failure.
=cut
sub download_file {
my ( $self, $remote_file, $local_file ) = @_;
$self->{connection}->get( $remote_file, $local_file )
or return $self->_abort_operation();
$self->add_message(
{
message => "Download succeeded",
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
=head3 change_directory
my $success = $server->change_directory($directory);
Passed a directory name, this will change the current directory of the server connection.
Returns true on success or undefined on failure.
=cut
sub change_directory {
my ( $self, $remote_directory ) = @_;
$self->{connection}->cwd($remote_directory) or $self->_abort_operation();
$self->add_message(
{
message => "Changed directory succeeded",
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
=head3 list_files
my @files = $server->list_files;
Returns an array of filenames found in the current directory of the server connection.
=cut
sub list_files {
my ($self) = @_;
my $file_list = $self->{connection}->ls or return $self->_abort_operation();
$self->add_message(
{
message => "Listing files succeeded",
type => 'success',
payload => { detail => '' }
}
);
return $file_list;
}
sub _abort_operation {
my ( $self, $message ) = @_;
$self->add_message(
{
message => $self->{connection} ? $self->{connection}->message : $@,
type => 'error',
payload => { detail => $self->{connection} ? $self->{connection}->status : '' }
}
);
if ( $self->{connection} ) {
$self->{connection}->abort;
}
return;
}
1;

142
Koha/File/Transport/SFTP.pm Normal file
View File

@ -0,0 +1,142 @@
package Koha::File::Transport::SFTP;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Logger;
use Net::SFTP::Foreign;
use Try::Tiny;
use base qw(Koha::File::Transport);
=head1 NAME
Koha::File::Transport::SFTP - SFTP implementation of file transport
=cut
sub connect {
my ($self) = @_;
$self->{connection} = Net::SFTP::Foreign->new(
host => $self->host,
user => $self->user_name,
password => $self->plain_text_password,
timeout => $self->DEFAULT_TIMEOUT,
more => [qw(-o StrictHostKeyChecking=no)],
);
$self->{connection}->die_on_error("SFTP failure for remote host");
return $self->_abort_operation() if ( $self->{connection}->error );
$self->add_message(
{
message => $self->{connection}->status,
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
sub upload_file {
my ( $self, $local_file, $remote_file ) = @_;
my $logger = Koha::Logger->get_logger();
$self->{connection}->put( $local_file, $remote_file ) or return $self->_abort_operation();
$self->add_message(
{
message => $self->{connection}->status,
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
sub download_file {
my ( $self, $remote_file, $local_file ) = @_;
$self->{connection}->get( $remote_file, $local_file ) or return $self->_abort_operation();
$self->add_message(
{
message => $self->{connection}->status,
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
sub change_directory {
my ( $self, $remote_directory ) = @_;
$self->{connection}->setcwd($remote_directory) or return $self->_abort_operation();
$self->add_message(
{
message => $self->{connection}->status,
type => 'success',
payload => { detail => '' }
}
);
return 1;
}
sub list_files {
my ($self) = @_;
my $file_list = $self->{connection}->ls or return $self->_abort_operation();
$self->add_message(
{
message => $self->{connection}->status,
type => 'success',
payload => { detail => '' }
}
);
return $file_list;
}
sub _abort_operation {
my ($self) = @_;
$self->add_message(
{
message => $self->{connection}->error,
type => 'error',
payload => { detail => $self->{connection} ? $self->{connection}->status : '' }
}
);
if ( $self->{connection} ) {
$self->{connection}->abort;
}
return;
}
1;

86
Koha/File/Transports.pm Normal file
View File

@ -0,0 +1,86 @@
package Koha::File::Transports;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Database;
use Koha::Exceptions;
use Koha::File::Transport::SFTP;
use Koha::File::Transport::FTP;
use base qw(Koha::Objects);
=head1 NAME
Koha::File::Transports - Koha File Transports Object set class
=head1 API
=head2 Internal methods
=head3 _type
Return type of object, relating to Schema ResultSet
=cut
sub _type {
return 'SftpServer';
}
=head3 _polymorphic_field
Return the field in the table that defines the polymorphic class to be built
=cut
sub _polymorphic_field {
return 'transport'; # This field defines which subclass to use
}
=head3 _polymorphic_map
Return the mapping for field value to class name for the polymorphic class
=cut
sub _polymorphic_map {
return {
sftp => 'Koha::File::Transport::SFTP',
ftp => 'Koha::File::Transport::FTP',
};
}
=head3 object_class
Return object class dynamically based on transport
=cut
sub object_class {
my ( $self, $object ) = @_;
return 'Koha::File::Transport' unless $object;
my $field = $self->_polymorphic_field;
my $map = $self->_polymorphic_map;
return $map->{ lc( $object->$field ) } || 'Koha::File::Transport';
}
1;

View File

@ -0,0 +1,174 @@
#!/usr/bin/perl
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Test::More tests => 5;
use Test::Exception;
use Test::NoWarnings;
use Test::Warn;
use Koha::File::Transports;
use t::lib::TestBuilder;
use t::lib::Mocks;
my $schema = Koha::Database->new->schema;
my $builder = t::lib::TestBuilder->new;
subtest 'store() tests' => sub {
plan tests => 6;
$schema->storage->txn_begin;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => {
transport => 'ftp',
password => undef,
key_file => undef,
download_directory => undef,
upload_directory => undef
}
}
);
subtest 'Test store with empty directories' => sub {
plan tests => 2;
$transport->set( { download_directory => '', upload_directory => '' } )->store();
is( $transport->download_directory, '', 'Empty download directory remains empty' );
is( $transport->upload_directory, '', 'Empty upload directory remains empty' );
};
subtest 'Test store with directories missing trailing slash' => sub {
plan tests => 2;
$transport->set( { download_directory => '/tmp/download', upload_directory => '/tmp/upload' } )->store();
is( $transport->download_directory, '/tmp/download/', 'Added trailing slash to download directory' );
is( $transport->upload_directory, '/tmp/upload/', 'Added trailing slash to upload directory' );
};
subtest 'Test store with directories having trailing slash' => sub {
plan tests => 2;
$transport->set( { download_directory => '/tmp/download/', upload_directory => '/tmp/upload/' } )->store();
is( $transport->download_directory, '/tmp/download/', 'Kept existing trailing slash in download directory' );
is( $transport->upload_directory, '/tmp/upload/', 'Kept existing trailing slash in upload directory' );
};
subtest 'Test store with mixed trailing slashes' => sub {
plan tests => 2;
$transport->set( { download_directory => '/tmp/download', upload_directory => '/tmp/upload/' } )->store();
is( $transport->download_directory, '/tmp/download/', 'Added missing trailing slash to download directory' );
is( $transport->upload_directory, '/tmp/upload/', 'Kept existing trailing slash in upload directory' );
};
subtest 'Test store with undefined directories' => sub {
plan tests => 2;
$transport->set( { download_directory => undef, upload_directory => undef } )->store();
is( $transport->download_directory, undef, 'Undefined download directory remains undefined' );
is( $transport->upload_directory, undef, 'Undefined upload directory remains undefined' );
};
subtest 'Test encryption of sensitive data' => sub {
plan tests => 2;
$transport->set( { password => "test123", key_file => "test321" } )->store();
isnt( $transport->password, "test123", 'Password is encrypted on store' );
isnt( $transport->upload_directory, "test321", 'Key file is encrypted on store' );
};
$schema->storage->txn_rollback;
};
subtest 'to_api() tests' => sub {
plan tests => 2;
$schema->storage->txn_begin;
my $transport = $builder->build_object( { class => 'Koha::File::Transports', value => { status => undef } } );
ok( !exists $transport->to_api->{password}, 'Password is not part of the API representation' );
ok( !exists $transport->to_api->{key_file}, 'Key file is not part of the API representation' );
$schema->storage->txn_rollback;
};
subtest 'plain_text_password() tests' => sub {
plan tests => 2;
$schema->storage->txn_begin;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => {
transport => 'ftp',
password => undef,
key_file => undef,
}
}
);
$transport->password('test123')->store();
my $transport_plain_text_password = $transport->plain_text_password;
isnt( $transport_plain_text_password, $transport->password, 'Password and password hash shouldn\'t match' );
is( $transport_plain_text_password, 'test123', 'Password should be in plain text' );
$schema->storage->txn_rollback;
};
subtest 'plain_text_key() tests' => sub {
plan tests => 2;
$schema->storage->txn_begin;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => {
transport => 'ftp',
password => undef,
key_file => undef
}
}
);
$transport->key_file("test321")->store();
my $transport_plain_text_key = $transport->plain_text_key;
isnt( $transport_plain_text_key, $transport->key_file, 'Key file and key file hash shouldn\'t match' );
is( $transport_plain_text_key, "test321\n", 'Key file should be in plain text' );
$schema->storage->txn_rollback;
};
1;

View File

@ -0,0 +1,94 @@
#!/usr/bin/perl
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Test::More tests => 6;
use Test::Exception;
use Test::NoWarnings;
use Test::Warn;
use Koha::File::Transports;
use t::lib::TestBuilder;
use t::lib::Mocks;
my $schema = Koha::Database->new->schema;
my $builder = t::lib::TestBuilder->new;
subtest 'connect() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'ftp', password => 'testpass' }
}
);
can_ok( $transport, 'connect' );
};
subtest 'upload_file() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'ftp', password => 'testpass' }
}
);
can_ok( $transport, 'upload_file' );
};
subtest 'download_file() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'ftp', password => 'testpass' }
}
);
can_ok( $transport, 'download_file' );
};
subtest 'change_directory() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'ftp', password => 'testpass' }
}
);
can_ok( $transport, 'change_directory' );
};
subtest 'list_files() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'ftp', password => 'testpass' }
}
);
can_ok( $transport, 'list_files' );
};
1;

View File

@ -0,0 +1,95 @@
#!/usr/bin/perl
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Test::More tests => 5;
use Test::Exception;
use Test::NoWarnings;
use Test::Warn;
use Koha::File::Transports;
use t::lib::TestBuilder;
use t::lib::Mocks;
my $schema = Koha::Database->new->schema;
my $builder = t::lib::TestBuilder->new;
subtest 'connect() tests' => sub {
plan tests => 2;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'sftp', password => 'testpass' }
}
);
can_ok( $transport, 'connect' );
dies_ok { $transport->connect } 'connect() should die without proper setup';
};
subtest 'upload_file() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'sftp', password => 'testpass' }
}
);
can_ok( $transport, 'upload_file' );
};
subtest 'download_file() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'sftp', password => 'testpass' }
}
);
can_ok( $transport, 'download_file' );
};
subtest 'change_directory() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'sftp', password => 'testpass' }
}
);
can_ok( $transport, 'change_directory' );
};
subtest 'list_files() tests' => sub {
plan tests => 1;
my $transport = $builder->build_object(
{
class => 'Koha::File::Transports',
value => { transport => 'sftp', password => 'testpass' }
}
);
can_ok( $transport, 'list_files' );
};
1;