@@ -, +, @@ files at once. and public column). we could have some files with errors and others without errors. The upload is now marked as Failed only if there was no upload at all. hook). Note that we do not want the -utf8 flag here for binary uploads. Deleting an upload is now presented via the search results form. public. Check the results in the table. same category. One upload should succeed. Check for reported error. In an empty 856$u, click the tag editor. Upload a file and click Choose. Save the record. Open the record in the OPAC and click the link. Copy this link to your clipboard for step 5. Choose for Delete. Open the link in your clipboard again. with some diacritical characters in the filename. (Bonus points for adding special chars in the category code.) --- Koha/Upload.pm | 123 +++++++- cataloguing/value_builder/upload.pl | 153 ++------- koha-tmpl/intranet-tmpl/prog/en/js/file-upload.js | 6 +- .../prog/en/modules/offline_circ/process_koc.tt | 2 +- .../prog/en/modules/tools/stage-marc-import.tt | 2 +- .../prog/en/modules/tools/upload-images.tt | 2 +- .../intranet-tmpl/prog/en/modules/tools/upload.tt | 332 ++++++++++++++++++++ tools/upload-file.pl | 41 ++- tools/upload.pl | 98 ++++++ 9 files changed, 600 insertions(+), 159 deletions(-) create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/tools/upload.tt create mode 100755 tools/upload.pl --- a/Koha/Upload.pm +++ a/Koha/Upload.pm @@ -51,6 +51,7 @@ use constant BYTES_DIGEST => 2048; use Modern::Perl; use CGI; # no utf8 flag, since it may interfere with binary uploads use Digest::MD5; +use Encode; use File::Spec; use IO::File; use Time::HiRes; @@ -58,6 +59,7 @@ use Time::HiRes; use base qw(Class::Accessor); use C4::Context; +use C4::Koha; __PACKAGE__->mk_ro_accessors( qw||); @@ -94,6 +96,17 @@ sub cgi { } } +=head2 count + + Returns number of uploaded files without errors + +=cut + +sub count { + my ( $self ) = @_; + return scalar grep { !exists $self->{files}->{$_}->{errcode} } keys $self->{files}; +} + =head2 result Returns new object based on Class::Accessor. @@ -102,8 +115,10 @@ sub cgi { sub result { my ( $self ) = @_; - my @a = map { $self->{files}->{$_}->{id} } keys $self->{files}; - return join ',', @a; + my @a = map { $self->{files}->{$_}->{id} } + grep { !exists $self->{files}->{$_}->{errcode} } + keys $self->{files}; + return @a? ( join ',', @a ): undef; } =head2 err @@ -136,11 +151,17 @@ sub get { my ( @rv, $res); foreach my $r ( @$temp ) { undef $res; + foreach( qw[id hashvalue filesize categorycode public] ) { + $res->{$_} = $r->{$_}; + } $res->{name} = $r->{filename}; - $res->{path}= $self->_full_fname($r); + $res->{path} = $self->_full_fname($r); if( $res->{path} && -r $res->{path} ) { - $res->{fh} = IO::File->new( $res->{path}, "r" ) - if $params->{filehandle}; + if( $params->{filehandle} ) { + my $fh = IO::File->new( $res->{path}, "r" ); + $fh->binmode if $fh; + $res->{fh} = $fh; + } push @rv, $res; } else { $self->{files}->{ $r->{filename} }->{errcode}=5; #not readable @@ -150,9 +171,59 @@ sub get { return wantarray? @rv: $res; } +=head2 delete + + Returns array of deleted filenames or undef. + Since it now only accepts id as parameter, you should not expect more + than one filename. + +=cut + +sub delete { + my ( $self, $params ) = @_; + return if !$params->{id}; + my @res; + my $temp = $self->_lookup({ id => $params->{id} }); + foreach( @$temp ) { + my $d = $self->_delete( $_ ); + push @res, $d if $d; + } + return if !@res; + return @res; +} + sub DESTROY { } +# ************** HELPER ROUTINES / CLASS METHODS ****************************** + +=head2 getCategories + + getCategories returns a list of upload category codes and names + +=cut + +sub getCategories { + my ( $class ) = @_; + my $cats = C4::Koha::GetAuthorisedValues('UPLOAD'); + [ map {{ code => $_->{authorised_value}, name => $_->{lib} }} @$cats ]; +} + +=head2 httpheaders + + httpheaders returns http headers for a retrievable upload + Will be extended by report 14282 + +=cut + +sub httpheaders { + my ( $class, $name ) = @_; + return ( + '-type' => 'application/octet-stream', + '-attachment' => $name, + ); +} + # ************** INTERNAL ROUTINES ******************************************** sub _init { @@ -192,7 +263,9 @@ sub _create_file { } else { my $dir = $self->_dir; my $fn = $self->{files}->{$filename}->{hash}. '_'. $filename; - if( -e "$dir/$fn" ) { + if( -e "$dir/$fn" && @{ $self->_lookup({ + hashvalue => $self->{files}->{$filename}->{hash} }) } ) { + # if the file exists and it is registered, then set error $self->{files}->{$filename}->{errcode} = 1; #already exists return; } @@ -232,6 +305,7 @@ sub _full_fname { sub _hook { my ( $self, $filename, $buffer, $bytes_read, $data ) = @_; + $filename= Encode::decode_utf8( $filename ); # UTF8 chars in filename $self->_compute( $filename, $buffer ); my $fh = $self->_fh( $filename ) // $self->_create_file( $filename ); print $fh $buffer if $fh; @@ -269,19 +343,46 @@ sub _register { sub _lookup { my ( $self, $params ) = @_; my $dbh = C4::Context->dbh; - my $sql = 'SELECT id,hashvalue,filename,dir,categorycode '. + my $sql = 'SELECT id,hashvalue,filename,dir,filesize,categorycode,public '. 'FROM uploaded_files '; + my @pars; if( $params->{id} ) { - $sql.= "WHERE id=?"; - } else { + return [] if $params->{id} !~ /^\d+(,\d+)*$/; + $sql.= "WHERE id IN ($params->{id})"; + @pars = (); + } elsif( $params->{hashvalue} ) { $sql.= "WHERE hashvalue=?"; + @pars = ( $params->{hashvalue} ); + } elsif( $params->{term} ) { + $sql.= "WHERE (filename LIKE ? OR hashvalue LIKE ?)"; + @pars = ( '%'.$params->{term}.'%', '%'.$params->{term}.'%' ); + } else { + return []; } $sql.= $self->{public}? " AND public=1": ''; - my $temp= $dbh->selectall_arrayref( $sql, { Slice => {} }, - ( $params->{id} // $params->{hashvalue} // 0 ) ); + $sql.= ' ORDER BY id'; + my $temp= $dbh->selectall_arrayref( $sql, { Slice => {} }, @pars ); return $temp; } +sub _delete { + my ( $self, $rec ) = @_; + my $dbh = C4::Context->dbh; + my $sql = 'DELETE FROM uploaded_files WHERE id=?'; + my $file = $self->_full_fname($rec); + if( !-e $file ) { # we will just delete the record + # TODO Should we add a trace here for the missing file? + $dbh->do( $sql, undef, ( $rec->{id} ) ); + return $rec->{filename}; + } elsif( unlink($file) ) { + $dbh->do( $sql, undef, ( $rec->{id} ) ); + return $rec->{filename}; + } + $self->{files}->{ $rec->{filename} }->{errcode} = 7; + #NOTE: errcode=6 is used to report successful delete (see template) + return; +} + sub _compute { # Computes hash value when sub hook feeds the first block # For temporary files, the id is made unique with time --- a/cataloguing/value_builder/upload.pl +++ a/cataloguing/value_builder/upload.pl @@ -4,6 +4,7 @@ # This file is part of Koha. # +# Copyright (C) 2015 Rijksmuseum # Copyright (C) 2011-2012 BibLibre # # Koha is free software; you can redistribute it and/or modify it @@ -20,145 +21,33 @@ # along with Koha; if not, see . use Modern::Perl; -use CGI qw/-utf8/; -use C4::Auth; -use C4::Context; -use C4::Output; -use C4::UploadedFiles; +# This plugin does not use the plugin launcher. It refers to tools/upload.pl. +# That script and template support using it as a plugin. + +# If the plugin is called with the pattern [id=some_hashvalue] in the +# corresponding field, it starts the upload script as a search, providing +# the possibility to delete the uploaded file. If the field is empty, you +# can upload a new file. my $builder = sub { my ( $params ) = @_; - my $function_name = $params->{id}; - my $res = " - -"; - return $res; -}; - -my $launcher = sub { - my ( $params ) = @_; - my $input = $params->{cgi}; - my $index = $input->param('index'); - my $id = $input->param('id'); - my $delete = $input->param('delete'); - my $uploaded_file = $input->param('uploaded_file'); - my $from_popup = $input->param('from_popup'); - my $isfileuploadurl = $input->param('IsFileUploadUrl'); - my $dangling = C4::UploadedFiles::DanglingEntry($id,$isfileuploadurl); - my $template_name; - if ($delete || ($id && ($dangling==0 || $dangling==1))) { - $template_name = "upload_delete_file.tt"; - } - else { - $template_name = "upload.tt"; - } - - my ( $template, $loggedinuser, $cookie ) = get_template_and_user( - { template_name => "cataloguing/value_builder/$template_name", - query => $input, - type => "intranet", - authnotrequired => 0, - flagsrequired => { editcatalogue => '*' }, - debug => 1, - } - ); - - if ($dangling==2) { - $template->param( dangling => 1 ); - } - - # Dealing with the uploaded file - my $dir = $input->param('uploadcategory'); - if ($uploaded_file and $dir) { - my $fh = $input->upload('uploaded_file'); - - $id = C4::UploadedFiles::UploadFile($uploaded_file, $dir, $fh->handle); - my $OPACBaseURL = C4::Context->preference('OPACBaseURL') // ''; - $OPACBaseURL =~ s#/$##; - if (!$OPACBaseURL) { - $template->param(MissingURL => 1); - } - if($id && $OPACBaseURL) { - my $return = "$OPACBaseURL/cgi-bin/koha/opac-retrieve-file.pl?id=$id"; - $template->param( - success => 1, - return => $return, - uploaded_file => $uploaded_file, - ); - } else { - $template->param(error => 1); - } - } elsif ($delete || ($id && ($dangling==0 || $dangling==1))) { - # If there's already a file uploaded for this field, - # We handle its deletion - if ($delete) { - if(C4::UploadedFiles::DelUploadedFile($id)==0) {; - $template->param(error => 1); + var str = document.getElementById(index).value; + var myurl, term; + if( str && str.match(/id=([0-9a-f]+)/) ) { + term = RegExp.\$1; + myurl = '../tools/upload.pl?op=search&index='+index+'&term='+term+'&plugin=1'; } else { - $template->param(success => 1); + myurl = '../tools/upload.pl?op=new&index='+index+'&plugin=1'; } + window.open( myurl, 'tag_editor', 'width=800,height=400,toolbar=false,scrollbars=yes' ); } - } else { - my $upload_path = C4::Context->config('upload_path'); - if ($upload_path) { - my $filefield = CGI::filefield( - -name => 'uploaded_file', - -size => 50, - ); - $template->param( - filefield => $filefield, - uploadcategories => C4::UploadedFiles::getCategories(), - ); - } else { - $template->param( error_upload_path_not_configured => 1 ); - } - - if (!$uploaded_file && !$dir && $from_popup) { - $template->param(error_nothing_selected => 1); - } - elsif (!$uploaded_file && $dir) { - $template->param(error_no_file_selected => 1); - } - if ($uploaded_file and not $dir) { - $template->param(error_no_dir_selected => 1); - } - - } - - $template->param( - index => $index, - id => $id, - ); - - output_html_with_http_headers $input, $cookie, $template->output; + +SCRIPT }; -return { builder => $builder, launcher => $launcher }; - -1; - -__END__ - -=head1 upload.pl - -This plugin allows to upload files on the server and reference it in a marc -field. - -It uses config variable upload_path and pref OPACBaseURL. - -=cut +return { builder => $builder }; --- a/koha-tmpl/intranet-tmpl/prog/en/js/file-upload.js +++ a/koha-tmpl/intranet-tmpl/prog/en/js/file-upload.js @@ -1,4 +1,4 @@ -function AjaxUpload ( input, progressbar, callback ) { +function AjaxUpload ( input, progressbar, xtra, callback ) { // input and progressbar are jQuery objects // callback is the callback function for completion var formData= new FormData(); @@ -6,7 +6,7 @@ function AjaxUpload ( input, progressbar, callback ) { formData.append( "uploadfile", file ); }); var xhr= new XMLHttpRequest(); - var url= '/cgi-bin/koha/tools/upload-file.pl'; + var url= '/cgi-bin/koha/tools/upload-file.pl?' + xtra; progressbar.val( 0 ); progressbar.next('.fileuploadpercent').text( '0' ); xhr.open('POST', url, true); @@ -21,7 +21,7 @@ function AjaxUpload ( input, progressbar, callback ) { progressbar.val( 100 ); progressbar.next('.fileuploadpercent').text( '100' ); } - callback( data.status, data.fileid ); + callback( data.status, data.fileid, data.errors ); } xhr.onerror = function (e) { // Probably only fires for network failure --- a/koha-tmpl/intranet-tmpl/prog/en/modules/offline_circ/process_koc.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/offline_circ/process_koc.tt @@ -20,7 +20,7 @@ function StartUpload() { $("#fileuploadstatus").show(); $("form#processfile #uploadedfileid").val(''); $("form#enqueuefile #uploadedfileid").val(''); - xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), cbUpload ); + xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), '', cbUpload ); } function cbUpload( status, fileid ) { --- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/stage-marc-import.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/stage-marc-import.tt @@ -39,7 +39,7 @@ function StartUpload() { $("#processfile").hide(); $("#fileuploadstatus").show(); $("#uploadedfileid").val(''); - xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), cbUpload ); + xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), '', cbUpload ); $("#fileuploadcancel").show(); } function CancelUpload() { --- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/upload-images.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/upload-images.tt @@ -18,7 +18,7 @@ function StartUpload() { $('#uploadform button.submit').prop('disabled',true); $("#fileuploadstatus").show(); $("#uploadedfileid").val(''); - xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), cbUpload ); + xhr= AjaxUpload( $('#fileToUpload'), $('#fileuploadprogress'), '', cbUpload ); } function cbUpload( status, fileid ) { if( status=='done' ) { --- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/upload.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/upload.tt @@ -0,0 +1,332 @@ +[% INCLUDE 'doc-head-open.inc' %] +[% USE Koha %] +[% IF plugin %] + Upload plugin +[% ELSE %] + Koha › Tools › Upload +[% END %] +[% INCLUDE 'doc-head-close.inc' %] + +[% BLOCK plugin_pars %] + [% IF plugin %] + + + [% END %] +[% END %] + +[% BLOCK breadcrumbs %] + +[% END %] + +[% BLOCK form_new %] +
+ [% PROCESS plugin_pars %] +
+ Upload new files +
    +
  1. +
    + + +
    +
  2. +
  3. + + +
  4. + [% IF !plugin %] +
  5. +
    Note: For temporary uploads do not select a category. The file will not be made available for public downloading.
    +
  6. + [% END %] +
  7. + [% IF plugin %] + + [% ELSE %] + + + Allow public downloads + + [% END %] +
  8. +
+
+ + +
+
+
+
Upload progress: + + + 0% +
+
+
+
+[% END %] + +[% BLOCK form_search %] +
+ [% PROCESS plugin_pars %] + +
+ Search uploads by name or hashvalue +
    +
  1. + + +
  2. +
  3. +
    + +
    +
  4. +
+
+
+[% END %] + +[% BLOCK submitter %] + +[% END %] + +[% BLOCK closer %] + [% IF plugin %] +
+
+ +
+
+ [% END %] +[% END %] + +[% BLOCK newsearch %] +
+
+ + [% IF plugin %] + + [% END %] +
+
+[% END %] + +[% BLOCK table_results %] + + + + + + + [% FOREACH record IN uploads %] + + + + + + [% IF !plugin %] + + [% END %] + + + [% END %] + +
Filename + Size + Hashvalue + Category + [% IF !plugin %]Public[% END %] + Actions +
[% record.name %][% record.filesize %][% record.hashvalue %][% record.categorycode %][% IF record.public %]Yes[% ELSE %]No[% END %] + [% IF plugin %] + Choose  + [% END %] + Download  + Delete +
+[% END %] + + + + + + + + + +[% IF !plugin %] + [% INCLUDE 'header.inc' %] + [% INCLUDE 'cat-search.inc' %] + [% PROCESS breadcrumbs %] +[% END %] + +
+
+
+
+ +

Upload

+ + + +[% PROCESS submitter %] +[% IF mode == 'new' || mode == 'deleted' %] + [% PROCESS form_new %] + [% PROCESS form_search %] + [% PROCESS closer %] +[% ELSIF mode == 'report' %] + [% IF uploads %] +

Your request gave the following results:

+ [% PROCESS table_results %] + [% PROCESS closer %] + [% ELSE %] +

Sorry, your request had no results.

+ [% PROCESS newsearch %] + [% END %] +[% END %] + +
+
+
+ +[% INCLUDE 'intranet-bottom.inc' %] --- a/tools/upload-file.pl +++ a/tools/upload-file.pl @@ -18,10 +18,13 @@ # along with Koha; if not, see . use Modern::Perl; -use CGI::Cookie; use CGI qw ( -utf8 ); -#use CGI::Session; +use CGI::Cookie; +use Encode; +use JSON; +use URI::Escape; + use C4::Context; use C4::Auth qw/check_cookie_auth haspermission/; use Koha::Upload; @@ -57,18 +60,36 @@ if ($auth_failure) { exit 0; } -my $upload = Koha::Upload->new({ }); -if( !$upload || !$upload->cgi || $upload->err ) { - send_reply( 'failed' ); +my $upload = Koha::Upload->new( extr_pars($ENV{QUERY_STRING}) ); +if( !$upload || !$upload->cgi || !$upload->count ) { + # not one upload succeeded + send_reply( 'failed', undef, $upload? $upload->err: undef ); } else { - send_reply( 'done', $upload->result ); + # in case of multiple uploads, at least one got through + send_reply( 'done', $upload->result, $upload->err ); } exit 0; sub send_reply { # response will be sent back as JSON - my ( $upload_status, $data ) = @_; + my ( $upload_status, $data, $error ) = @_; my $reply = CGI->new(""); - print $reply->header(-type => 'text/html'); - print '{"status":"' . $upload_status . - ( $data? '","fileid":"' . $data: '' ) . '"}'; + print $reply->header( -type => 'text/html', -charset => 'UTF-8' ); + print JSON::encode_json({ + status => $upload_status, + fileid => $data, + errors => $error, + }); +} + +sub extr_pars { + my ( $qstr ) = @_; + $qstr = Encode::decode_utf8( uri_unescape( $qstr ) ); + # category could include a utf8 character + my $rv = {}; + foreach my $p ( qw[public category] ) { + if( $qstr =~ /(^|&)$p=(\w+)(&|$)/ ) { + $rv->{$p} = $2; + } + } + return $rv; } --- a/tools/upload.pl +++ a/tools/upload.pl @@ -0,0 +1,98 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Copyright (C) 2015 Rijksmuseum +# +# 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 . + +use Modern::Perl; +use CGI qw/-utf8/; +use JSON; + +use C4::Auth; +use C4::Output; +use Koha::Upload; + +my $input = CGI::->new; +my $op = $input->param('op') // 'new'; +my $plugin = $input->param('plugin'); +my $index = $input->param('index'); # MARC editor input field id +my $term = $input->param('term'); +my $id = $input->param('id'); +my $msg = $input->param('msg'); + +my ( $template, $loggedinuser, $cookie ) = get_template_and_user( + { template_name => "tools/upload.tt", + query => $input, + type => "intranet", + authnotrequired => 0, + flagsrequired => { editcatalogue => '*' }, + } +); + +$template->param( + plugin => $plugin, + index => $index, +); + +my $upar = $plugin? { public => 1 }: {}; +if( $op eq 'new' ) { + $template->param( + mode => 'new', + uploadcategories => Koha::Upload->getCategories, + ); + output_html_with_http_headers $input, $cookie, $template->output; +} elsif( $op eq 'search' ) { + my $h = $id? { id => $id }: { term => $term }; + my @uploads = Koha::Upload->new( $upar )->get( $h ); + $template->param( + mode => 'report', + msg => $msg, + uploads => \@uploads, + ); + output_html_with_http_headers $input, $cookie, $template->output; +} elsif( $op eq 'delete' ) { + # delete only takes the id parameter + my $upl = Koha::Upload->new( $upar ); + my ( $fn ) = $upl->delete({ id => $id }); + my $e = $upl->err; + my $msg = $fn? JSON::to_json({ $fn => 6 }): + $e? JSON::to_json( $e ): undef; + $template->param( + mode => 'deleted', + msg => $msg, + uploadcategories => $upl->getCategories, + ); + output_html_with_http_headers $input, $cookie, $template->output; +} elsif( $op eq 'download' ) { + my $upl = Koha::Upload->new( $upar ); + my $rec = $upl->get({ id => $id, filehandle => 1 }); + my $fh = $rec->{fh}; + if( !$rec || !$fh ) { + $template->param( + mode => 'new', + msg => JSON::to_json({ $id => 5 }), + uploadcategories => $upl->getCategories, + ); + output_html_with_http_headers $input, $cookie, $template->output; + } else { + my @hdr = $upl->httpheaders( $rec->{name} ); + print $input->header( @hdr ); + while( <$fh> ) { + print $_; + } + $fh->close; + } +} --