Bug 35198: Sort database column names alphabetically on automatic item modification...
[koha.git] / tools / picture-upload.pl
1 #!/usr/bin/perl
2 #
3 #
4 # This file is part of Koha.
5 #
6 # Koha is free software; you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # Koha is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with Koha; if not, see <http://www.gnu.org/licenses>.
18 #
19 #
20 #
21
22 use Modern::Perl;
23
24 use File::Temp;
25 use CGI qw ( -utf8 );
26 use GD;
27 use MIME::Base64;
28 use C4::Context;
29 use C4::Auth qw( get_template_and_user );
30 use C4::Output qw( output_and_exit output_html_with_http_headers );
31 use C4::Members;
32
33 use Koha::Logger;
34 use Koha::Patrons;
35 use Koha::Patron::Images;
36 use Koha::Token;
37
38 my $input = CGI->new;
39
40 unless (C4::Context->preference('patronimages')) {
41     # redirect to intranet home if patronimages is not enabled
42     print $input->redirect("/cgi-bin/koha/mainpage.pl");
43     exit;
44 }
45
46 my ($template, $loggedinuser, $cookie)
47     = get_template_and_user({template_name => "tools/picture-upload.tt",
48                                         query => $input,
49                                         type => "intranet",
50                                         flagsrequired => { tools => 'batch_upload_patron_images'},
51                                         });
52
53 our $filetype      = $input->param('filetype') || '';
54 my $cardnumber     = $input->param('cardnumber');
55 our $uploadfilename = $input->param('uploadfile') || $input->param('uploadfilename') || '';
56 my $uploadfiletext = $input->param('uploadfiletext') || '';
57 my $uploadfile     = $input->upload('uploadfile');
58 my $borrowernumber = $input->param('borrowernumber');
59 my $op             = $input->param('op') || '';
60
61 #FIXME: This code is really in the rough. The variables need to be re-scoped as the two subs depend on global vars to operate.
62 #       Other parts of this code could be optimized as well, I think. Perhaps the file upload could be done with YUI's upload
63 #       coded. -fbcit
64
65 our $logger = Koha::Logger->get;
66 $logger->debug("Params are: filetype=$filetype, cardnumber=$cardnumber, borrowernumber=$borrowernumber, uploadfile=$uploadfilename");
67
68 =head1 NAME
69
70 picture-upload.pl - Script for handling uploading of both single and bulk patronimages and importing them into the database.
71
72 =head1 SYNOPSIS
73
74 picture-upload.pl
75
76 =head1 DESCRIPTION
77
78 This script is called and presents the user with an interface allowing him/her to upload a single patron image or bulk patron images via a zip file.
79 Files greater than 100K will be refused. Images should be 140x200 pixels. If they are larger they will be auto-resized to comply.
80
81 =cut
82
83 my ( $total, $handled, $tempfile, $tfh );
84 our @counts = ();
85 our %errors = ();
86
87 # Case is important in these operational values as the template must use case to be visually pleasing!
88 if ( ( $op eq 'Upload' ) && ($uploadfile || $uploadfiletext) ) {
89
90     output_and_exit( $input, $cookie, $template, 'wrong_csrf_token' )
91         unless Koha::Token->new->check_csrf({
92             session_id => scalar $input->cookie('CGISESSID'),
93             token  => scalar $input->param('csrf_token'),
94         });
95
96     my $dirname = File::Temp::tempdir( CLEANUP => 1 );
97     my $filesuffix;
98     if ( $uploadfilename =~ m/(\..+)$/i ) {
99         $filesuffix = $1;
100     }
101     ( $tfh, $tempfile ) =
102       File::Temp::tempfile( SUFFIX => $filesuffix, UNLINK => 1 );
103     my ( @directories, $results );
104
105     $errors{'NOWRITETEMP'} = 1 unless ( -w $dirname );
106     if ( length($uploadfiletext) == 0 ) {
107         $errors{'NOTZIP'} = 1
108           if ( $uploadfilename !~ /\.zip$/i && $filetype =~ m/zip/i );
109         $errors{'EMPTYUPLOAD'} = 1 unless ( length($uploadfile) > 0 );
110     }
111
112     if (%errors) {
113         $template->param( ERRORS => [ \%errors ] );
114         output_html_with_http_headers $input, $cookie, $template->output;
115         exit;
116     }
117
118     if ( length($uploadfiletext) == 0 ) {
119         while (<$uploadfile>) {
120             print $tfh $_;
121         }
122     } else {
123         # data type controlled in toDataURL() in template
124         if ( $uploadfiletext =~ /data:image\/jpeg;base64,(.*)/ ) {
125             my $encoded_picture = $1;
126             my $decoded_picture = decode_base64($encoded_picture);
127             print $tfh $decoded_picture;
128         } else {
129             $errors{'BADPICTUREDATA'} = 1;
130             $template->param( ERRORS => [ \%errors ] );
131             output_html_with_http_headers $input, $cookie, $template->output;
132             exit;
133         }
134     }
135     close $tfh;
136     if ( $filetype eq 'zip' ) {
137         qx/unzip $tempfile -d $dirname/;
138         my $exit_code = $?;
139         unless ( $exit_code == 0 ) {
140             $errors{'UZIPFAIL'} = $uploadfilename;
141             $template->param( ERRORS => [ \%errors ] );
142             # This error is fatal to the import, so bail out here
143             output_html_with_http_headers $input, $cookie, $template->output;
144             exit;
145         }
146         push @directories, "$dirname";
147         foreach my $recursive_dir (@directories) {
148             my $recdir_h;
149             opendir $recdir_h, $recursive_dir;
150             while ( my $entry = readdir $recdir_h ) {
151                 push @directories, "$recursive_dir/$entry"
152                   if ( -d "$recursive_dir/$entry" and $entry !~ /^\./ );
153             }
154             closedir $recdir_h;
155         }
156         foreach my $dir (@directories) {
157             $results = handle_dir( $dir, $filesuffix, $template );
158             $handled++ if $results == 1;
159         }
160         $total = scalar @directories;
161     }
162     else {
163         #if ($filetype eq 'zip' )
164         $results = handle_dir( $dirname, $filesuffix, $template, $cardnumber,
165             $tempfile );
166         $handled++ if $results == 1;
167         $total   = 1;
168     }
169
170     if ( $results!=1 || %errors ) {
171         $template->param( ERRORS => [$results] );
172     }
173     else {
174         my $filecount;
175         map { $filecount += $_->{count} } @counts;
176         $logger->debug("Total directories processed: $total");
177         $logger->debug("Total files processed: $filecount");
178         $template->param(
179             TOTAL   => $total,
180             HANDLED => $handled,
181             COUNTS  => \@counts,
182             TCOUNTS => ( $filecount > 0 ? $filecount : undef ),
183         );
184         $template->param( borrowernumber => $borrowernumber )
185           if $borrowernumber;
186     }
187 }
188 elsif ( ( $op eq 'Upload' ) && !$uploadfile ) {
189     warn "Problem uploading file or no file uploaded.";
190     $template->param( cardnumber => $cardnumber );
191     $template->param( filetype   => $filetype );
192 }
193 elsif ( $op eq 'Delete' ) {
194     output_and_exit( $input, $cookie, $template, 'wrong_csrf_token' )
195         unless Koha::Token->new->check_csrf({
196             session_id => scalar $input->cookie('CGISESSID'),
197             token  => scalar $input->param('csrf_token'),
198         });
199
200     my $deleted = eval {
201         Koha::Patron::Images->find( $borrowernumber )->delete;
202     };
203     if ( $@ or not $deleted ) {
204         warn "Image for patron '$borrowernumber' has not been deleted";
205     }
206 }
207 if ( $borrowernumber && !%errors && !$template->param('ERRORS') ) {
208     print $input->redirect(
209         "/cgi-bin/koha/members/moremember.pl?borrowernumber=$borrowernumber");
210 }
211 else {
212     $template->param(
213         csrf_token => Koha::Token->new->generate_csrf({
214             session_id => scalar $input->cookie('CGISESSID'),
215         }),
216     );
217     output_html_with_http_headers $input, $cookie, $template->output;
218 }
219
220 sub handle_dir {
221     my ( $dir, $suffix, $template, $cardnumber, $source ) = @_;
222     my ( %counts, %direrrors );
223     $logger->debug("Entering sub handle_dir; passed \$dir=$dir, \$suffix=$suffix");
224     if ( $suffix =~ m/zip/i ) {
225         # If we were sent a zip file, process any included data/idlink.txt files
226         my ( $file, $filename );
227         undef $cardnumber;
228         $logger->debug("Passed a zip file.");
229         my $dir_h;
230         opendir $dir_h, $dir;
231         while ( my $filename = readdir $dir_h ) {
232             $file = "$dir/$filename"
233               if ( $filename =~ m/datalink\.txt/i
234                 || $filename =~ m/idlink\.txt/i );
235         }
236         my $fh;
237         unless ( open( $fh, '<', $file ) ) {
238             warn "Opening $dir/$file failed!";
239             $direrrors{'OPNLINK'} = $file;
240             # This error is fatal to the import of this directory contents
241             # so bail and return the error to the caller
242             return \%direrrors;
243         }
244
245         my @lines = <$fh>;
246         close $fh;
247         foreach my $line (@lines) {
248             $logger->debug("Reading contents of $file");
249             chomp $line;
250             $logger->debug("Examining line: $line");
251             my $delim = ( $line =~ /\t/ ) ? "\t" : ( $line =~ /,/ ) ? "," : "";
252             $logger->debug("Delimeter is \'$delim\'");
253             unless ( $delim eq "," || $delim eq "\t" ) {
254                 warn "Unrecognized or missing field delimeter. Please verify that you are using either a ',' or a 'tab'";
255                 $direrrors{'DELERR'} = 1;
256                 # This error is fatal to the import of this directory contents
257                 # so bail and return the error to the caller
258                 return \%direrrors;
259             }
260             ( $cardnumber, $filename ) = split $delim, $line;
261             $cardnumber =~ s/[\"\r\n]//g; # remove offensive characters
262             $filename   =~ s/[\"\r\n\s]//g;
263             $logger->debug("Cardnumber: $cardnumber Filename: $filename");
264             $source = "$dir/$filename";
265             %counts = handle_file( $cardnumber, $source, $template, %counts );
266         }
267         closedir $dir_h;
268     }
269     else {
270         %counts = handle_file( $cardnumber, $source, $template, %counts );
271     }
272     push @counts, \%counts;
273     return 1;
274 }
275
276 sub handle_file {
277     my ( $cardnumber, $source, $template, %count ) = @_;
278     $logger->debug("Entering sub handle_file; passed \$cardnumber=$cardnumber, \$source=$source");
279     $count{filenames} = ()      if !$count{filenames};
280     $count{source}    = $source if !$count{source};
281     $count{count}     = 0       unless exists $count{count};
282     my %filerrors;
283     my $filename;
284     if ( $filetype eq 'image' ) {
285         $filename = $uploadfilename;
286     }
287     else {
288         $filename = $1 if ( $source && $source =~ /\/([^\/]+)$/ );
289     }
290     if ( $cardnumber && $source ) {
291         # Now process any imagefiles
292         $logger->debug("Source: $source");
293         my $size = ( stat($source) )[7];
294         if ( $size > 2097152 ) {
295             # This check is necessary even with image resizing to avoid possible security/performance issues...
296             $filerrors{'OVRSIZ'} = 1;
297             push my @filerrors, \%filerrors;
298             push @{ $count{filenames} },
299               {
300                 filerrors  => \@filerrors,
301                 source     => $filename,
302                 cardnumber => $cardnumber
303               };
304             $template->param( ERRORS => 1 );
305             # this one is fatal so bail here...
306             return %count;
307         }
308         my ( $srcimage, $image );
309         if ( open( my $fh, '<', $source ) ) {
310             $srcimage = GD::Image->new($fh);
311             close($fh);
312             if ( defined $srcimage ) {
313                 my $imgfile;
314                 my $mimetype = 'image/png';
315                 # GD autodetects three basic image formats: PNG, JPEG, XPM
316                 # we will convert all to PNG which is lossless...
317                 # Check the pixel size of the image we are about to import...
318                 my ( $width, $height ) = $srcimage->getBounds();
319                 $logger->debug("$filename is $width pix X $height pix.");
320                 if ( $width > 200 || $height > 300 ) {
321                     # MAX pixel dims are 200 X 300...
322                     $logger->debug("$filename exceeds the maximum pixel dimensions of 200 X 300. Resizing...");
323                     # Percent we will reduce the image dimensions by...
324                     my $percent_reduce;
325                     if ( $width > 200 ) {
326                         # If the width is oversize, scale based on width overage...
327                         $percent_reduce = sprintf( "%.5f", ( 140 / $width ) );
328                     }
329                     else {
330                         # otherwise scale based on height overage.
331                         $percent_reduce = sprintf( "%.5f", ( 200 / $height ) );
332                     }
333                     my $width_reduce =
334                       sprintf( "%.0f", ( $width * $percent_reduce ) );
335                     my $height_reduce =
336                       sprintf( "%.0f", ( $height * $percent_reduce ) );
337                       $logger->debug("Reducing $filename by "
338                       . ( $percent_reduce * 100 )
339                       . "\% or to $width_reduce pix X $height_reduce pix");
340                     #'1' creates true color image...
341                     $image = GD::Image->new( $width_reduce, $height_reduce, 1 );
342                     $image->copyResampled( $srcimage, 0, 0, 0, 0, $width_reduce,
343                         $height_reduce, $width, $height );
344                     $imgfile = $image->png();
345                     $logger->debug("$filename is "
346                       . length($imgfile)
347                       . " bytes after resizing.");
348                     undef $image;
349                     undef $srcimage; # This object can get big...
350                 }
351                 else {
352                     $image   = $srcimage;
353                     $imgfile = $image->png();
354                     $logger->debug("$filename is " . length($imgfile) . " bytes.");
355                     undef $image;
356                     undef $srcimage; # This object can get big...
357                 }
358                 $logger->debug("Image is of mimetype $mimetype");
359                 if ($mimetype) {
360                     my $patron = Koha::Patrons->find({ cardnumber => $cardnumber });
361                     if ( $patron ) {
362                         my $image = $patron->image;
363                         $image ||= Koha::Patron::Image->new({ borrowernumber => $patron->borrowernumber });
364                         $image->set({
365                             mimetype => $mimetype,
366                             imagefile => $imgfile,
367                         });
368                         eval { $image->store };
369                         if ( $@ ) {
370                             # Errors from here on are fatal only to the import of a particular image
371                             #so don't bail, just note the error and keep going
372                             warn "Database returned error: $@";
373                             $filerrors{'DBERR'} = 1;
374                             push my @filerrors, \%filerrors;
375                             push @{ $count{filenames} },
376                               {
377                                 filerrors  => \@filerrors,
378                                 source     => $filename,
379                                 cardnumber => $cardnumber
380                               };
381                             $template->param( ERRORS => 1 );
382                         } else {
383                             $count{count}++;
384                             push @{ $count{filenames} },
385                               { source => $filename, cardnumber => $cardnumber };
386                         }
387                     } else {
388                         warn "Patron with the cardnumber '$cardnumber' does not exist";
389                         $filerrors{'CARDNUMBER_DOES_NOT_EXIST'} = 1;
390                         push my @filerrors, \%filerrors;
391                         push @{ $count{filenames} },
392                           {
393                             filerrors  => \@filerrors,
394                             source     => $filename,
395                             cardnumber => $cardnumber
396                           };
397                         $template->param( ERRORS => 1 );
398                     }
399                 }
400                 else {
401                     warn "Unable to determine mime type of $filename. Please verify mimetype.";
402                     $filerrors{'MIMERR'} = 1;
403                     push my @filerrors, \%filerrors;
404                     push @{ $count{filenames} },
405                       {
406                         filerrors  => \@filerrors,
407                         source     => $filename,
408                         cardnumber => $cardnumber
409                       };
410                     $template->param( ERRORS => 1 );
411                 }
412             }
413             else {
414                 warn "Contents of $filename corrupted!";
415                 #$count{count}--;
416                 $filerrors{'CORERR'} = 1;
417                 push my @filerrors, \%filerrors;
418                 push @{ $count{filenames} },
419                   {
420                     filerrors  => \@filerrors,
421                     source     => $filename,
422                     cardnumber => $cardnumber
423                   };
424                 $template->param( ERRORS => 1 );
425             }
426         }
427         else {
428             warn "Opening $source failed!";
429             $filerrors{'OPNERR'} = 1;
430             push my @filerrors, \%filerrors;
431             push @{ $count{filenames} },
432               {
433                 filerrors  => \@filerrors,
434                 source     => $filename,
435                 cardnumber => $cardnumber
436               };
437             $template->param( ERRORS => 1 );
438         }
439     }
440     else {
441         # The need for this seems a bit unlikely, however, to maximize error trapping it is included
442         warn "Missing "
443           . (
444             $cardnumber
445             ? "filename"
446             : ( $filename ? "cardnumber" : "cardnumber and filename" )
447           );
448         $filerrors{'CRDFIL'} = (
449             $cardnumber
450             ? "filename"
451             : ( $filename ? "cardnumber" : "cardnumber and filename" )
452         );
453         push my @filerrors, \%filerrors;
454         push @{ $count{filenames} },
455           {
456             filerrors  => \@filerrors,
457             source     => $filename,
458             cardnumber => $cardnumber
459           };
460         $template->param( ERRORS => 1 );
461     }
462     return (%count);
463 }
464
465 =head1 AUTHORS
466
467 Original contributor(s) undocumented
468
469 Database storage, single patronimage upload option, and extensive error trapping contributed by Chris Nighswonger cnighswonger <at> foundations <dot> edu
470 Image scaling/resizing contributed by the same.
471
472 =cut